use core_api::{
schema::Schema, BatchOp, Direction, GraphDb, GraphError, Predicate, RoleDef, RuleDef, Value,
WriteScope, MERGE_CREATE_NEEDS_ONE_NAMESPACE,
};
use std::collections::BTreeMap;
fn tmp(name: &str) -> std::path::PathBuf {
let nanos = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.expect("clock")
.as_nanos();
let d = std::env::temp_dir().join(format!(
"graphdb-ws-{}-{}-{}",
name,
std::process::id(),
nanos
));
let _ = std::fs::remove_dir_all(&d);
d
}
fn no_params() -> BTreeMap<String, Value> {
BTreeMap::new()
}
fn wal_len(dir: &std::path::Path) -> u64 {
std::fs::metadata(dir.join("wal.bin"))
.map(|m| m.len())
.unwrap_or(0)
}
fn writer_role() -> RoleDef {
RoleDef {
name: "writer".into(),
keys: vec![],
labels: vec!["MyLabel".into(), "Visible".into()],
visible_where: None,
namespaces: None,
write: Some(WriteScope {
create_labels: vec!["MyLabel".into()],
update_labels: vec!["MyLabel".into(), "Visible".into()],
delete_labels: vec!["MyLabel".into()],
create_edge_types: vec!["KNOWS".into()],
delete_edge_types: vec!["KNOWS".into()],
}),
}
}
fn open_with_writer(name: &str) -> (GraphDb<core_storage::fs::RealFs>, std::path::PathBuf) {
let dir = tmp(name);
let mut db = GraphDb::open(&dir).unwrap();
let schema = Schema {
fulltext: vec![],
indexes: vec![],
rules: vec![],
views: vec![],
roles: vec![writer_role()],
};
db.apply_schema(&schema).unwrap();
(db, dir)
}
fn writer_authz(db: &mut GraphDb<core_storage::fs::RealFs>) -> core_api::WriteAuthz {
let roles = db.roles();
let def = roles.iter().find(|r| r.name == "writer").unwrap();
let scope = def.write.clone().unwrap();
let mask = db.mask_for_role("writer").unwrap();
core_api::WriteAuthz {
role: "writer".into(),
scope,
mask,
}
}
fn is_role_write_denied(e: &GraphError) -> bool {
matches!(e, GraphError::RoleWriteDenied { .. })
}
fn denied_reason(e: &GraphError) -> &str {
match e {
GraphError::RoleWriteDenied { reason } => reason.as_str(),
_ => panic!("expected RoleWriteDenied, got {e:?}"),
}
}
#[test]
fn test_create_allowed() {
let (mut db, _dir) = open_with_writer("create-allowed");
let authz = writer_authz(&mut db);
let ops = vec![BatchOp::InsertNode {
label: "MyLabel".into(),
key: "n1".into(),
props: vec![],
}];
let (nodes, _) = db.write_batch_authz(Some(&authz), ops).unwrap();
assert_eq!(nodes, 1);
assert!(db.has_node("n1"));
}
#[test]
fn test_create_scope_denied_empty_store() {
let (mut db, _dir) = open_with_writer("create-scope-denied");
let authz = writer_authz(&mut db);
assert!(!db.has_node("x"));
let ops = vec![BatchOp::InsertNode {
label: "Secret".into(), key: "x".into(),
props: vec![],
}];
let err = db.write_batch_authz(Some(&authz), ops).unwrap_err();
assert!(
is_role_write_denied(&err),
"expected RoleWriteDenied, got {err:?}"
);
assert!(
denied_reason(&err).contains("create_labels"),
"reason should name create_labels: {}",
denied_reason(&err)
);
assert!(!db.has_node("x"));
}
#[test]
fn test_create_hidden_collision() {
let (mut db, _dir) = open_with_writer("create-hidden-collision");
db.insert_node("Secret", "secret_key", vec![]).unwrap();
let authz = writer_authz(&mut db);
let ops = vec![BatchOp::InsertNode {
label: "MyLabel".into(),
key: "secret_key".into(),
props: vec![],
}];
let err = db.write_batch_authz(Some(&authz), ops).unwrap_err();
assert!(
is_role_write_denied(&err),
"expected RoleWriteDenied, got {err:?}"
);
assert_eq!(
denied_reason(&err),
"role-bound token: target node not visible",
"hidden key must return not-visible, not DuplicateKey"
);
}
#[test]
fn test_create_visible_collision() {
let (mut db, _dir) = open_with_writer("create-visible-collision");
db.insert_node("MyLabel", "alice", vec![]).unwrap();
let authz = writer_authz(&mut db);
let ops = vec![BatchOp::InsertNode {
label: "MyLabel".into(),
key: "alice".into(),
props: vec![],
}];
let err = db.write_batch_authz(Some(&authz), ops).unwrap_err();
assert!(
matches!(err, GraphError::DuplicateKey { .. }),
"visible collision must return DuplicateKey, got {err:?}"
);
}
#[test]
fn test_update_visible_allowed() {
let (mut db, _dir) = open_with_writer("update-allowed");
db.insert_node("MyLabel", "alice", vec![]).unwrap();
let authz = writer_authz(&mut db);
let ops = vec![BatchOp::SetProp {
key: "alice".into(),
field: "name".into(),
value: Value::Str("Alice".into()),
}];
db.write_batch_authz(Some(&authz), ops).unwrap();
assert_eq!(
db.get_prop("alice", "name"),
Some(Value::Str("Alice".into()))
);
}
#[test]
fn test_update_scope_denied() {
let dir = tmp("update-scope-denied");
let mut db = GraphDb::open(&dir).unwrap();
let schema = Schema {
fulltext: vec![],
indexes: vec![],
rules: vec![],
views: vec![],
roles: vec![RoleDef {
name: "reader_writer".into(),
keys: vec![],
labels: vec!["MyLabel".into(), "ReadOnly".into()],
visible_where: None,
namespaces: None,
write: Some(WriteScope {
create_labels: vec!["MyLabel".into()],
update_labels: vec!["MyLabel".into()], delete_labels: vec![],
create_edge_types: vec![],
delete_edge_types: vec![],
}),
}],
};
db.apply_schema(&schema).unwrap();
db.insert_node("ReadOnly", "ro_node", vec![]).unwrap();
let roles = db.roles();
let def = roles.iter().find(|r| r.name == "reader_writer").unwrap();
let scope = def.write.clone().unwrap();
let mask = db.mask_for_role("reader_writer").unwrap();
let authz = core_api::WriteAuthz {
role: "reader_writer".into(),
scope,
mask,
};
let ops = vec![BatchOp::SetProp {
key: "ro_node".into(),
field: "x".into(),
value: Value::Int(1),
}];
let err = db.write_batch_authz(Some(&authz), ops).unwrap_err();
assert!(
is_role_write_denied(&err),
"expected RoleWriteDenied, got {err:?}"
);
assert!(
denied_reason(&err).contains("update_labels"),
"reason should name update_labels: {}",
denied_reason(&err)
);
}
#[test]
fn test_update_hidden_identical_to_absent() {
let (mut db, _dir) = open_with_writer("update-hidden-absent");
db.insert_node("Secret", "hidden_node", vec![]).unwrap();
let authz = writer_authz(&mut db);
let hidden_ops = vec![BatchOp::SetProp {
key: "hidden_node".into(),
field: "x".into(),
value: Value::Int(1),
}];
let absent_ops = vec![BatchOp::SetProp {
key: "nonexistent".into(), field: "x".into(),
value: Value::Int(1),
}];
let err_hidden = db.write_batch_authz(Some(&authz), hidden_ops).unwrap_err();
let err_absent = db.write_batch_authz(Some(&authz), absent_ops).unwrap_err();
assert_eq!(
denied_reason(&err_hidden),
denied_reason(&err_absent),
"hidden and absent must produce identical error messages (spec §3.1)"
);
assert_eq!(
denied_reason(&err_hidden),
"role-bound token: target node not visible"
);
}
#[test]
fn test_delete_node_allowed() {
let (mut db, _dir) = open_with_writer("delete-node-allowed");
db.insert_node("MyLabel", "del_me", vec![]).unwrap();
let authz = writer_authz(&mut db);
let ops = vec![BatchOp::DeleteNode {
key: "del_me".into(),
}];
db.write_batch_authz(Some(&authz), ops).unwrap();
assert!(!db.has_node("del_me"));
}
#[test]
fn test_delete_node_scope_denied() {
let (mut db, _dir) = open_with_writer("delete-node-scope-denied");
db.insert_node("Visible", "vis_node", vec![]).unwrap();
let authz = writer_authz(&mut db);
let ops = vec![BatchOp::DeleteNode {
key: "vis_node".into(),
}];
let err = db.write_batch_authz(Some(&authz), ops).unwrap_err();
assert!(
is_role_write_denied(&err),
"expected RoleWriteDenied, got {err:?}"
);
assert!(
denied_reason(&err).contains("delete_labels"),
"reason should name delete_labels: {}",
denied_reason(&err)
);
}
fn derived_rule() -> RuleDef {
RuleDef {
name: "sim".into(),
src_label: "MyLabel".into(),
dst_label: "MyLabel".into(),
predicate: Predicate::Overlap {
field: "tags".into(),
min: 0.5,
},
edge_type: "SIMILAR".into(),
weight_prop: None,
max_edges: None,
approximate: false,
via_label: None,
via_edge: None,
via_dir: None,
namespace: None,
}
}
#[test]
fn test_delete_edge_derived_before_scope() {
let dir = tmp("delete-edge-derived-order");
let mut db = GraphDb::open(&dir).unwrap();
let schema = Schema {
fulltext: vec![],
indexes: vec![],
rules: vec![derived_rule()],
views: vec![],
roles: vec![writer_role()],
};
db.apply_schema(&schema).unwrap();
db.insert_node(
"MyLabel",
"a",
vec![("tags".into(), Value::List(vec![Value::Str("x".into())]))],
)
.unwrap();
db.insert_node(
"MyLabel",
"b",
vec![("tags".into(), Value::List(vec![Value::Str("x".into())]))],
)
.unwrap();
let authz = writer_authz(&mut db);
let ops = vec![BatchOp::DeleteEdge {
edge_type: "SIMILAR".into(),
src_key: "a".into(),
dst_key: "b".into(),
}];
let err = db.write_batch_authz(Some(&authz), ops).unwrap_err();
assert!(
matches!(err, GraphError::RuleOwned { .. }),
"derived-edge rejection must precede scope check; got {err:?}"
);
}
#[test]
fn test_delete_edge_allowed() {
let (mut db, _dir) = open_with_writer("delete-edge-allowed");
db.insert_node("MyLabel", "a", vec![]).unwrap();
db.insert_node("MyLabel", "b", vec![]).unwrap();
db.insert_edge("KNOWS", "a", "b").unwrap();
let authz = writer_authz(&mut db);
let ops = vec![BatchOp::DeleteEdge {
edge_type: "KNOWS".into(),
src_key: "a".into(),
dst_key: "b".into(),
}];
db.write_batch_authz(Some(&authz), ops).unwrap();
let neighbors = db
.neighbors("a", "KNOWS", Direction::Out)
.unwrap_or_default();
assert!(
!neighbors.contains(&"b".to_string()),
"edge should be deleted"
);
}
#[test]
fn test_delete_edge_hidden_endpoint() {
let (mut db, _dir) = open_with_writer("delete-edge-hidden-ep");
db.insert_node("MyLabel", "a", vec![]).unwrap();
db.insert_node("Secret", "secret_b", vec![]).unwrap();
db.insert_edge("KNOWS", "a", "secret_b").unwrap();
let authz = writer_authz(&mut db);
let ops = vec![BatchOp::DeleteEdge {
edge_type: "KNOWS".into(),
src_key: "a".into(),
dst_key: "secret_b".into(),
}];
let err = db.write_batch_authz(Some(&authz), ops).unwrap_err();
assert!(
is_role_write_denied(&err),
"expected RoleWriteDenied, got {err:?}"
);
assert_eq!(
denied_reason(&err),
"role-bound token: edge endpoint not visible"
);
}
#[test]
fn test_delete_edge_type_not_scoped() {
let (mut db, _dir) = open_with_writer("delete-edge-unscoped");
db.insert_node("MyLabel", "a", vec![]).unwrap();
db.insert_node("MyLabel", "b", vec![]).unwrap();
db.insert_edge("UNSCOPED_TYPE", "a", "b").unwrap();
let authz = writer_authz(&mut db);
let ops = vec![BatchOp::DeleteEdge {
edge_type: "UNSCOPED_TYPE".into(),
src_key: "a".into(),
dst_key: "b".into(),
}];
let err = db.write_batch_authz(Some(&authz), ops).unwrap_err();
assert!(
is_role_write_denied(&err),
"expected RoleWriteDenied, got {err:?}"
);
assert!(
denied_reason(&err).contains("delete_edge_types"),
"reason should name delete_edge_types: {}",
denied_reason(&err)
);
}
#[test]
fn test_merge_unscoped_no_key_lookup() {
let (mut db, _dir) = open_with_writer("merge-unscoped");
let err = db
.query_write_authz("writer", "MERGE (n:Secret {id: 'x'})", &no_params())
.unwrap_err();
assert!(
is_role_write_denied(&err),
"expected RoleWriteDenied for unscoped MERGE, got {err:?}"
);
assert_eq!(
denied_reason(&err),
"role-bound token: label 'Secret' not in write scope (create_labels)",
"unscoped MERGE must return exact §4.3 reason string"
);
assert!(
!db.has_node("x"),
"scope denial must fire before key lookup"
);
}
#[test]
fn test_merge_create_arm() {
let (mut db, _dir) = open_with_writer("merge-create");
let result = db
.query_write_authz("writer", "MERGE (n:MyLabel {id: 'new_node'})", &no_params())
.unwrap();
assert!(
db.has_node("new_node"),
"MERGE create arm must create the node"
);
let _ = result;
}
#[test]
fn test_merge_match_arm() {
let (mut db, _dir) = open_with_writer("merge-match");
db.insert_node("MyLabel", "existing", vec![]).unwrap();
db.query_write_authz(
"writer",
"MERGE (n:MyLabel {id: 'existing'}) ON MATCH SET n.updated = 1",
&no_params(),
)
.unwrap();
assert_eq!(
db.get_prop("existing", "updated"),
Some(Value::Int(1)),
"MERGE match arm must update the property"
);
}
#[test]
fn test_merge_hidden_key() {
let (mut db, _dir) = open_with_writer("merge-hidden");
db.insert_node("Secret", "hidden_key", vec![]).unwrap();
let err = db
.query_write_authz(
"writer",
"MERGE (n:MyLabel {id: 'hidden_key'})",
&no_params(),
)
.unwrap_err();
assert!(
is_role_write_denied(&err),
"MERGE on hidden key must return RoleWriteDenied, got {err:?}"
);
assert_eq!(
denied_reason(&err),
"role-bound token: target node not visible"
);
}
#[test]
fn test_merge_update_only_hidden_eq_absent() {
let dir = tmp("merge-update-only");
let mut db = GraphDb::open(&dir).unwrap();
let schema = Schema {
fulltext: vec![],
indexes: vec![],
rules: vec![],
views: vec![],
roles: vec![RoleDef {
name: "updater".into(),
keys: vec![],
labels: vec!["MyLabel".into()],
visible_where: None,
namespaces: None,
write: Some(WriteScope {
create_labels: vec![], update_labels: vec!["MyLabel".into()],
delete_labels: vec![],
create_edge_types: vec![],
delete_edge_types: vec![],
}),
}],
};
db.apply_schema(&schema).unwrap();
db.insert_node("Secret", "hidden_node", vec![]).unwrap();
let err_hidden = db
.query_write_authz(
"updater",
"MERGE (n:MyLabel {id: 'hidden_node'})",
&no_params(),
)
.unwrap_err();
let err_absent = db
.query_write_authz(
"updater",
"MERGE (n:MyLabel {id: 'absent_node'})",
&no_params(),
)
.unwrap_err();
assert!(is_role_write_denied(&err_hidden), "hidden: {err_hidden:?}");
assert!(is_role_write_denied(&err_absent), "absent: {err_absent:?}");
assert_eq!(
denied_reason(&err_hidden),
denied_reason(&err_absent),
"update-only role: hidden and absent MERGE bodies must be equal"
);
assert_eq!(
denied_reason(&err_hidden),
"role-bound token: target node not visible"
);
assert!(
!db.has_node("absent_node"),
"absent node must not be created by update-only MERGE"
);
}
#[test]
fn test_merge_create_only_disclosure_pinned() {
let dir = tmp("merge-create-only-disclosure");
let mut db = GraphDb::open(&dir).unwrap();
let schema = Schema {
fulltext: vec![],
indexes: vec![],
rules: vec![],
views: vec![],
roles: vec![RoleDef {
name: "creator".into(),
keys: vec![],
labels: vec!["MyLabel".into()],
visible_where: None,
namespaces: None,
write: Some(WriteScope {
create_labels: vec!["MyLabel".into()],
update_labels: vec![],
delete_labels: vec![],
create_edge_types: vec![],
delete_edge_types: vec![],
}),
}],
};
db.apply_schema(&schema).unwrap();
db.insert_node("Secret", "hidden_key", vec![]).unwrap();
let err_hidden = db
.query_write_authz(
"creator",
"MERGE (n:MyLabel {id: 'hidden_key'})",
&no_params(),
)
.unwrap_err();
assert_eq!(
denied_reason(&err_hidden),
"role-bound token: target node not visible",
"create-only: hidden key must be not-visible"
);
db.query_write_authz(
"creator",
"MERGE (n:MyLabel {id: 'new_node'})",
&no_params(),
)
.unwrap();
assert!(
db.has_node("new_node"),
"create-only role: absent key must create the node"
);
}
#[test]
fn test_merge_on_create_set_with_role_authz() {
let (mut db, _dir) = open_with_writer("merge-on-create-set");
db.query_write_authz(
"writer",
"MERGE (n:MyLabel {id: 'new_mc'}) ON CREATE SET n.created = 1",
&no_params(),
)
.unwrap();
assert!(
db.has_node("new_mc"),
"MERGE ON CREATE SET must create the node"
);
assert_eq!(
db.get_prop("new_mc", "created"),
Some(Value::Int(1)),
"ON CREATE SET property must be applied"
);
let dir2 = tmp("merge-on-create-set-create-only");
let mut db2 = GraphDb::open(&dir2).unwrap();
let schema = core_api::schema::Schema {
fulltext: vec![],
indexes: vec![],
rules: vec![],
views: vec![],
roles: vec![RoleDef {
name: "creator".into(),
keys: vec![],
labels: vec!["MyLabel".into()],
visible_where: None,
namespaces: None,
write: Some(WriteScope {
create_labels: vec!["MyLabel".into()],
update_labels: vec![], delete_labels: vec![],
create_edge_types: vec![],
delete_edge_types: vec![],
}),
}],
};
db2.apply_schema(&schema).unwrap();
db2.query_write_authz(
"creator",
"MERGE (n:MyLabel {id: 'creator_node'}) ON CREATE SET n.x = 42",
&no_params(),
)
.unwrap();
assert!(db2.has_node("creator_node"));
assert_eq!(
db2.get_prop("creator_node", "x"),
Some(Value::Int(42)),
"batch-created node updatable by same batch regardless of update_labels"
);
}
#[test]
fn test_edge_create_both_visible() {
let (mut db, _dir) = open_with_writer("edge-create-both-vis");
db.insert_node("MyLabel", "a", vec![]).unwrap();
db.insert_node("MyLabel", "b", vec![]).unwrap();
let authz = writer_authz(&mut db);
let ops = vec![BatchOp::InsertEdge {
edge_type: "KNOWS".into(),
src_key: "a".into(),
dst_key: "b".into(),
}];
db.write_batch_authz(Some(&authz), ops).unwrap();
let neighbors = db
.neighbors("a", "KNOWS", Direction::Out)
.unwrap_or_default();
assert!(neighbors.contains(&"b".to_string()), "edge should exist");
}
#[test]
fn test_edge_create_one_hidden() {
let (mut db, _dir) = open_with_writer("edge-create-hidden-ep");
db.insert_node("MyLabel", "a", vec![]).unwrap();
db.insert_node("Secret", "hidden_b", vec![]).unwrap();
let authz = writer_authz(&mut db);
let ops = vec![BatchOp::InsertEdge {
edge_type: "KNOWS".into(),
src_key: "a".into(),
dst_key: "hidden_b".into(),
}];
let err = db.write_batch_authz(Some(&authz), ops).unwrap_err();
assert!(
is_role_write_denied(&err),
"expected RoleWriteDenied, got {err:?}"
);
assert_eq!(
denied_reason(&err),
"role-bound token: edge endpoint not visible"
);
}
#[test]
fn test_edge_create_type_not_scoped() {
let (mut db, _dir) = open_with_writer("edge-create-unscoped");
db.insert_node("MyLabel", "a", vec![]).unwrap();
db.insert_node("MyLabel", "b", vec![]).unwrap();
let authz = writer_authz(&mut db);
let ops = vec![BatchOp::InsertEdge {
edge_type: "UNSCOPED".into(), src_key: "a".into(),
dst_key: "b".into(),
}];
let err = db.write_batch_authz(Some(&authz), ops).unwrap_err();
assert!(
is_role_write_denied(&err),
"expected RoleWriteDenied, got {err:?}"
);
assert!(
denied_reason(&err).contains("create_edge_types"),
"reason should name create_edge_types: {}",
denied_reason(&err)
);
}
#[test]
fn test_upsert_placeholder_counts_as_visible() {
let (mut db, _dir) = open_with_writer("upsert-placeholder");
let authz = writer_authz(&mut db);
let ops = vec![
BatchOp::InsertNode {
label: "MyLabel".into(),
key: "new_src".into(),
props: vec![],
},
BatchOp::InsertNode {
label: "MyLabel".into(),
key: "new_dst".into(),
props: vec![],
},
BatchOp::InsertEdge {
edge_type: "KNOWS".into(),
src_key: "new_src".into(),
dst_key: "new_dst".into(),
},
];
db.write_batch_authz(Some(&authz), ops).unwrap();
assert!(db.has_node("new_src"));
assert!(db.has_node("new_dst"));
let neighbors = db
.neighbors("new_src", "KNOWS", Direction::Out)
.unwrap_or_default();
assert!(
neighbors.contains(&"new_dst".to_string()),
"edge should exist"
);
}
#[test]
fn test_upsert_direct_placeholder_visible() {
let (mut db, _dir) = open_with_writer("upsert-direct-placeholder");
let authz = writer_authz(&mut db);
let ops = vec![BatchOp::InsertEdgeUpsert {
edge_type: "KNOWS".into(),
src_key: "upsert_src".into(),
dst_key: "upsert_dst".into(),
placeholder_label: "MyLabel".into(),
}];
db.write_batch_authz(Some(&authz), ops).unwrap();
assert!(db.has_node("upsert_src"));
assert!(db.has_node("upsert_dst"));
let neighbors = db
.neighbors("upsert_src", "KNOWS", Direction::Out)
.unwrap_or_default();
assert!(
neighbors.contains(&"upsert_dst".to_string()),
"edge should exist"
);
}
#[test]
fn test_upsert_placeholder_not_in_create_labels() {
let (mut db, _dir) = open_with_writer("upsert-unscoped-placeholder");
let authz = writer_authz(&mut db);
let ops = vec![BatchOp::InsertEdgeUpsert {
edge_type: "KNOWS".into(),
src_key: "x".into(),
dst_key: "y".into(),
placeholder_label: "Secret".into(), }];
let err = db.write_batch_authz(Some(&authz), ops).unwrap_err();
assert!(
is_role_write_denied(&err),
"expected RoleWriteDenied, got {err:?}"
);
}
#[test]
fn test_batch_atomicity_no_wal_on_deny() {
let (mut db, dir) = open_with_writer("batch-atomic");
db.insert_node("MyLabel", "existing", vec![]).unwrap();
let seq_before = db.commit_seq();
let wal_before = wal_len(&dir);
let authz = writer_authz(&mut db);
let ops = vec![
BatchOp::InsertNode {
label: "MyLabel".into(),
key: "new_node".into(),
props: vec![],
},
BatchOp::InsertNode {
label: "Secret".into(), key: "bad_node".into(),
props: vec![],
},
];
let err = db.write_batch_authz(Some(&authz), ops).unwrap_err();
assert!(is_role_write_denied(&err));
assert!(
!db.has_node("new_node"),
"first op must not be applied on deny"
);
assert!(!db.has_node("bad_node"));
assert_eq!(
db.commit_seq(),
seq_before,
"commit_seq must not advance on deny"
);
let wal_after = wal_len(&dir);
assert_eq!(wal_after, wal_before, "WAL must not grow on deny");
}
#[test]
fn test_authz_fires_before_cas_would() {
let (mut db, _dir) = open_with_writer("authz-before-cas");
db.insert_node("Secret", "hidden", vec![]).unwrap();
let authz = writer_authz(&mut db);
let ops = vec![BatchOp::SetProp {
key: "hidden".into(),
field: "x".into(),
value: Value::Int(1),
}];
let err = db.write_batch_authz(Some(&authz), ops).unwrap_err();
assert!(
matches!(err, GraphError::RoleWriteDenied { .. }),
"authz must fire before CAS; expected RoleWriteDenied, got {err:?}"
);
assert_eq!(
denied_reason(&err),
"role-bound token: target node not visible"
);
}
#[test]
fn test_none_authz_full_authority() {
let (mut db, _dir) = open_with_writer("none-authz");
let ops = vec![BatchOp::InsertNode {
label: "Secret".into(),
key: "sec_node".into(),
props: vec![],
}];
db.write_batch_authz(None, ops).unwrap();
assert!(
db.has_node("sec_node"),
"None authz must bypass all role checks"
);
}
#[test]
fn test_rename_node_forbidden_for_role() {
let (mut db, _dir) = open_with_writer("rename-forbidden");
db.insert_node("MyLabel", "old_key", vec![]).unwrap();
let authz = writer_authz(&mut db);
let ops = vec![BatchOp::RenameNode {
old_key: "old_key".into(),
new_key: "new_key".into(),
}];
let err = db.write_batch_authz(Some(&authz), ops).unwrap_err();
assert!(
is_role_write_denied(&err),
"expected RoleWriteDenied, got {err:?}"
);
assert_eq!(
denied_reason(&err),
"role-bound token: this endpoint is not permitted"
);
}
#[test]
fn test_create_rule_forbidden_for_role() {
let (mut db, _dir) = open_with_writer("create-rule-forbidden");
let authz = writer_authz(&mut db);
let rule = RuleDef {
name: "test_rule".into(),
src_label: "MyLabel".into(),
dst_label: "MyLabel".into(),
predicate: Predicate::KeyMatch { field: "x".into() },
edge_type: "KNOWS".into(),
weight_prop: None,
max_edges: None,
approximate: false,
via_label: None,
via_edge: None,
via_dir: None,
namespace: None,
};
let ops = vec![BatchOp::CreateRule(rule)];
let err = db.write_batch_authz(Some(&authz), ops).unwrap_err();
assert!(
is_role_write_denied(&err),
"expected RoleWriteDenied, got {err:?}"
);
assert_eq!(
denied_reason(&err),
"role-bound token: this endpoint is not permitted"
);
}
#[test]
fn test_rules_fire_but_hidden_edges_masked() {
let dir = tmp("rules-fire-hidden-masked");
let mut db = GraphDb::open(&dir).unwrap();
let schema = Schema {
fulltext: vec![],
indexes: vec![],
rules: vec![derived_rule()],
views: vec![],
roles: vec![writer_role()],
};
db.apply_schema(&schema).unwrap();
db.insert_node(
"Secret",
"hidden_similar",
vec![("tags".into(), Value::List(vec![Value::Str("xyz".into())]))],
)
.unwrap();
let authz = writer_authz(&mut db);
let ops = vec![BatchOp::InsertNode {
label: "MyLabel".into(),
key: "role_node".into(),
props: vec![("tags".into(), Value::List(vec![Value::Str("xyz".into())]))],
}];
db.write_batch_authz(Some(&authz), ops).unwrap();
let mask = db.mask_for_role("writer").unwrap();
let masked_edges = db.node_edges_masked("role_node", &mask).unwrap();
assert!(
!masked_edges
.iter()
.any(|e| e.src_key == "hidden_similar" || e.dst_key == "hidden_similar"),
"hidden neighbor must not appear in masked edge list; edges: {masked_edges:?}"
);
}
#[test]
fn test_delete_recreate_setprop_respects_update_labels() {
let dir = tmp("delete-recreate-setprop");
let mut db = GraphDb::open(&dir).unwrap();
let schema = core_api::schema::Schema {
fulltext: vec![],
indexes: vec![],
rules: vec![],
views: vec![],
roles: vec![RoleDef {
name: "delcreate".into(),
keys: vec![],
labels: vec!["MyLabel".into()],
visible_where: None,
namespaces: None,
write: Some(WriteScope {
create_labels: vec!["MyLabel".into()],
update_labels: vec![], delete_labels: vec!["MyLabel".into()],
create_edge_types: vec![],
delete_edge_types: vec![],
}),
}],
};
db.apply_schema(&schema).unwrap();
db.insert_node("MyLabel", "existing", vec![]).unwrap();
let roles = db.roles();
let def = roles.iter().find(|r| r.name == "delcreate").unwrap();
let authz = core_api::WriteAuthz {
role: "delcreate".into(),
scope: def.write.clone().unwrap(),
mask: db.mask_for_role("delcreate").unwrap(),
};
let ops = vec![
BatchOp::DeleteNode {
key: "existing".into(),
},
BatchOp::InsertNode {
label: "MyLabel".into(),
key: "existing".into(),
props: vec![],
},
BatchOp::SetProp {
key: "existing".into(),
field: "x".into(),
value: Value::Int(1),
},
];
let err = db.write_batch_authz(Some(&authz), ops).unwrap_err();
assert!(
is_role_write_denied(&err),
"SetProp after delete+recreate must be denied when update_labels is empty; got {err:?}"
);
assert!(
denied_reason(&err).contains("update_labels"),
"reason must name update_labels: {}",
denied_reason(&err)
);
}
#[test]
fn test_merge_create_return_yields_node() {
let (mut db, _dir) = open_with_writer("merge-create-return");
let rs = db
.query_write_authz(
"writer",
"MERGE (n:MyLabel {id: 'new_m1'}) ON CREATE SET n.x = 1 RETURN n",
&no_params(),
)
.unwrap();
assert_eq!(
rs.len(),
1,
"MERGE create arm RETURN must yield exactly 1 row (read-after-write)"
);
assert_eq!(
rs.get(0, "n"),
Some(&Value::Str("new_m1".into())),
"RETURN n must project the created node key"
);
assert_eq!(
db.get_prop("new_m1", "x"),
Some(Value::Int(1)),
"ON CREATE SET must be applied"
);
}
#[test]
fn test_merge_match_return_unaffected() {
let (mut db, _dir) = open_with_writer("merge-match-return");
db.insert_node("MyLabel", "existing_m1", vec![]).unwrap();
let rs = db
.query_write_authz(
"writer",
"MERGE (n:MyLabel {id: 'existing_m1'}) RETURN n",
&no_params(),
)
.unwrap();
assert_eq!(
rs.len(),
1,
"MERGE match arm RETURN must yield exactly 1 row"
);
assert_eq!(
rs.get(0, "n"),
Some(&Value::Str("existing_m1".into())),
"RETURN n must project the matched node key"
);
}
#[test]
fn test_merge_create_no_mask_widening() {
let (mut db, _dir) = open_with_writer("merge-no-widen");
db.insert_node("Secret", "hidden_pre", vec![]).unwrap();
let rs = db
.query_write_authz(
"writer",
"MERGE (n:MyLabel {id: 'new_no_widen'}) RETURN n",
&no_params(),
)
.unwrap();
assert_eq!(rs.len(), 1, "create arm RETURN must yield 1 row");
assert_eq!(
rs.get(0, "n"),
Some(&Value::Str("new_no_widen".into())),
"RETURN must project the created node key, not hidden_pre or nothing"
);
let mask = db.mask_for_role("writer").unwrap();
let hidden_rs = db
.query_masked(
"MATCH (n:Secret {id: 'hidden_pre'}) RETURN n",
&no_params(),
&mask,
)
.unwrap();
assert_eq!(
hidden_rs.len(),
0,
"mask refresh must NOT widen to Secret label; hidden_pre must remain invisible"
);
}
#[test]
fn test_detach_delete_cascades_hidden_edges() {
let (mut db, _dir) = open_with_writer("detach-delete-cascade");
db.insert_node("MyLabel", "del_a", vec![]).unwrap();
db.insert_node("MyLabel", "surv_b", vec![]).unwrap();
db.insert_node("Secret", "hidden_c", vec![]).unwrap();
db.insert_edge("KNOWS", "del_a", "surv_b").unwrap();
db.insert_edge("KNOWS", "del_a", "hidden_c").unwrap();
db.query_write_authz(
"writer",
"MATCH (n:MyLabel {id: 'del_a'}) DETACH DELETE n",
&no_params(),
)
.unwrap();
assert!(!db.has_node("del_a"), "del_a must be deleted");
assert!(db.has_node("surv_b"), "surv_b must survive");
assert!(db.has_node("hidden_c"), "hidden_c must survive");
let b_in = db
.neighbors("surv_b", "KNOWS", Direction::In)
.unwrap_or_default();
assert!(
!b_in.contains(&"del_a".to_string()),
"edge del_a→surv_b must be cascade-deleted; b_in={b_in:?}"
);
let c_in = db
.neighbors("hidden_c", "KNOWS", Direction::In)
.unwrap_or_default();
assert!(
!c_in.contains(&"del_a".to_string()),
"edge del_a→hidden_c must be cascade-deleted regardless of mask; c_in={c_in:?}"
);
}
#[test]
fn test_predicate_narrows_the_write_target_set() {
let dir = tmp("predicate-write-scope");
let mut db = GraphDb::open(&dir).unwrap();
db.insert_node(
"MyLabel",
"open",
vec![("status".into(), Value::Str("published".into()))],
)
.unwrap();
db.insert_node(
"MyLabel",
"closed",
vec![("status".into(), Value::Str("draft".into()))],
)
.unwrap();
db.apply_schema(&Schema {
roles: vec![RoleDef {
visible_where: Some(core_api::PropPredicate {
field: "status".into(),
eq: None,
in_: Some(vec![Value::Str("published".into())]),
}),
..writer_role()
}],
..Default::default()
})
.unwrap();
let authz = {
let roles = db.roles();
let def = roles.iter().find(|r| r.name == "writer").unwrap();
core_api::WriteAuthz {
role: "writer".into(),
scope: def.write.clone().unwrap(),
mask: db.mask_for_role("writer").unwrap(),
}
};
assert_eq!(authz.mask.len(), 1, "only the published node is in scope");
db.write_batch_authz(
Some(&authz),
vec![BatchOp::SetProp {
key: "open".into(),
field: "note".into(),
value: Value::Str("ok".into()),
}],
)
.expect("a visible node of an update label is writable");
let hidden = db
.write_batch_authz(
Some(&authz),
vec![BatchOp::SetProp {
key: "closed".into(),
field: "note".into(),
value: Value::Str("no".into()),
}],
)
.expect_err("a node failing the predicate must not be writable");
let absent = db
.write_batch_authz(
Some(&authz),
vec![BatchOp::SetProp {
key: "never-existed".into(),
field: "note".into(),
value: Value::Str("no".into()),
}],
)
.expect_err("an absent key must not be writable either");
assert!(is_role_write_denied(&hidden));
assert_eq!(
denied_reason(&hidden),
denied_reason(&absent),
"hidden-by-predicate must be byte-equal to absent — no existence oracle"
);
}
fn tenant_writer_role() -> RoleDef {
RoleDef {
namespaces: Some(vec!["x".into()]),
..writer_role()
}
}
fn open_with_tenant_writer(name: &str) -> (GraphDb<core_storage::fs::RealFs>, std::path::PathBuf) {
let dir = tmp(name);
let mut db = GraphDb::open(&dir).unwrap();
db.apply_schema(&Schema {
roles: vec![tenant_writer_role()],
..Default::default()
})
.unwrap();
(db, dir)
}
fn ns_prop(n: &str) -> (String, Value) {
(core_api::NS_PROP.to_string(), Value::Str(n.to_string()))
}
#[test]
fn test_create_outside_the_roles_namespace_denied() {
let (mut db, _dir) = open_with_tenant_writer("create-foreign-ns");
let authz = writer_authz(&mut db);
db.write_batch_authz(
Some(&authz),
vec![BatchOp::InsertNode {
label: "MyLabel".into(),
key: "mine".into(),
props: vec![ns_prop("x")],
}],
)
.unwrap();
assert_eq!(db.namespace_of("mine").as_deref(), Some("x"));
let authz = writer_authz(&mut db);
let err = db
.write_batch_authz(
Some(&authz),
vec![BatchOp::InsertNode {
label: "MyLabel".into(),
key: "theirs".into(),
props: vec![ns_prop("y")],
}],
)
.unwrap_err();
assert!(is_role_write_denied(&err), "got {err:?}");
assert_eq!(
denied_reason(&err),
"role-bound token: namespace 'y' not in the role's namespaces"
);
assert!(!db.has_node("theirs"));
let authz = writer_authz(&mut db);
let err = db
.write_batch_authz(
Some(&authz),
vec![BatchOp::InsertNode {
label: "MyLabel".into(),
key: "bare".into(),
props: vec![],
}],
)
.unwrap_err();
assert_eq!(
denied_reason(&err),
"role-bound token: namespace 'default' not in the role's namespaces"
);
assert!(!db.has_node("bare"));
let authz = writer_authz(&mut db);
let err = db
.write_batch_authz(
Some(&authz),
vec![BatchOp::InsertEdgeUpsert {
edge_type: "KNOWS".into(),
src_key: "mine".into(),
dst_key: "ghost".into(),
placeholder_label: "MyLabel".into(),
}],
)
.unwrap_err();
assert_eq!(
denied_reason(&err),
"role-bound token: edge endpoint not visible"
);
assert!(!db.has_node("ghost"));
}
#[test]
fn test_cypher_create_outside_the_roles_namespace_denied() {
let (mut db, _dir) = open_with_tenant_writer("cypher-foreign-ns");
let err = db
.query_write_authz(
"writer",
"CREATE (n:MyLabel {id: 'theirs', ns: 'y'})",
&no_params(),
)
.unwrap_err();
assert_eq!(
denied_reason(&err),
"role-bound token: namespace 'y' not in the role's namespaces"
);
assert!(!db.has_node("theirs"));
let err = db
.query_write_authz(
"writer",
"MERGE (n:MyLabel {id: 'merged', ns: 'y'})",
&no_params(),
)
.unwrap_err();
assert_eq!(
denied_reason(&err),
"role-bound token: namespace 'y' not in the role's namespaces"
);
assert!(!db.has_node("merged"));
db.query_write_authz(
"writer",
"CREATE (n:MyLabel {id: 'mine', ns: 'x'})",
&no_params(),
)
.unwrap();
assert_eq!(db.namespace_of("mine").as_deref(), Some("x"));
}
#[test]
fn test_unscoped_role_may_create_in_any_namespace() {
let (mut db, _dir) = open_with_writer("create-unscoped-ns");
let authz = writer_authz(&mut db);
db.write_batch_authz(
Some(&authz),
vec![
BatchOp::InsertNode {
label: "MyLabel".into(),
key: "bare".into(),
props: vec![],
},
BatchOp::InsertNode {
label: "MyLabel".into(),
key: "tenanted".into(),
props: vec![ns_prop("y")],
},
],
)
.unwrap();
assert_eq!(db.namespace_of("bare").as_deref(), Some("default"));
assert_eq!(db.namespace_of("tenanted").as_deref(), Some("y"));
}
#[test]
fn test_upsert_placeholder_hidden_equals_absent_for_a_namespaced_role() {
let (mut db, _dir) = open_with_tenant_writer("upsert-oracle");
db.insert_node("MyLabel", "mine", vec![ns_prop("x")])
.unwrap();
db.insert_node("Secret", "hidden", vec![ns_prop("y")])
.unwrap();
let upsert = |key: &str| BatchOp::InsertEdgeUpsert {
edge_type: "KNOWS".into(),
src_key: "mine".into(),
dst_key: key.into(),
placeholder_label: "MyLabel".into(),
};
let authz = writer_authz(&mut db);
let hidden_err = db
.write_batch_authz(Some(&authz), vec![upsert("hidden")])
.unwrap_err();
let authz = writer_authz(&mut db);
let absent_err = db
.write_batch_authz(Some(&authz), vec![upsert("nobody")])
.unwrap_err();
assert_eq!(
denied_reason(&hidden_err),
denied_reason(&absent_err),
"hidden and absent must be indistinguishable"
);
assert_eq!(
denied_reason(&absent_err),
"role-bound token: edge endpoint not visible"
);
assert!(!db.has_node("nobody"), "nothing was created");
}
#[test]
fn test_merge_create_denied_for_a_namespaced_role() {
let dir = tmp("merge-ns-role");
let mut db = GraphDb::open(&dir).unwrap();
db.apply_schema(&Schema {
roles: vec![RoleDef {
namespaces: Some(vec!["x".into(), "y".into()]),
..writer_role()
}],
..Default::default()
})
.unwrap();
db.insert_node("MyLabel", "mine", vec![ns_prop("x")])
.unwrap();
let err = db
.query_write_authz("writer", "MERGE (n:MyLabel {id: 'fresh'})", &no_params())
.unwrap_err();
assert_eq!(
denied_reason(&err),
MERGE_CREATE_NEEDS_ONE_NAMESPACE,
"a two-namespace role must name one namespace to MERGE-create"
);
assert!(!db.has_node("fresh"));
db.query_write_authz(
"writer",
"MERGE (n:MyLabel {id: 'mine'}) ON MATCH SET n.seen = 1",
&no_params(),
)
.unwrap();
assert_eq!(db.get_prop("mine", "seen"), Some(Value::Int(1)));
assert_eq!(db.namespace_of("mine").as_deref(), Some("x"));
}
#[test]
fn test_duplicate_ns_refused_on_the_role_path() {
let (mut db, _dir) = open_with_tenant_writer("dup-ns-role");
let authz = writer_authz(&mut db);
let err = db
.write_batch_authz(
Some(&authz),
vec![BatchOp::InsertNode {
label: "MyLabel".into(),
key: "two".into(),
props: vec![ns_prop("x"), ns_prop("y")],
}],
)
.unwrap_err();
assert!(
err.to_string().contains("given more than once"),
"expected the duplicate-ns refusal, got {err:?}"
);
assert!(!db.has_node("two"));
}