test_relation_manager.cpp 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344
  1. // Task 1 — RelationManager: the declaration registry for referential
  2. // integrity relations. No enforcement, no LMDB index yet (later tasks).
  3. //
  4. // Modeled on tests/test_view_manager_paging.cpp: relations are keyed by
  5. // their project-qualified name in a `_relations` system collection, and
  6. // loadFromStore() must page explicitly since Query::limit defaults to 100
  7. // and limit=0 returns nothing (not everything) — the same trap that has
  8. // already shipped as a bug in ViewManager, PolicyManager and
  9. // CollectionConfigManager.
  10. #include <iostream>
  11. #include <string>
  12. #include <nlohmann/json.hpp>
  13. #include "document.hpp"
  14. #include "memory_store.hpp"
  15. #include "relations/relation_manager.hpp"
  16. using namespace smartbotic::database;
  17. namespace {
  18. int g_pass = 0;
  19. int g_fail = 0;
  20. void check(bool cond, const std::string& msg) {
  21. if (cond) { ++g_pass; }
  22. else { ++g_fail; std::cerr << "FAIL: " << msg << "\n"; }
  23. }
  24. struct Fixture {
  25. MemoryStore store;
  26. Fixture() : store(MemoryStore::Config{}) {
  27. store.start();
  28. }
  29. ~Fixture() { store.stop(); }
  30. };
  31. void test_relations_are_project_scoped_and_survive_reload() {
  32. Fixture f; // MemoryStore, started
  33. RelationManager rm(f.store);
  34. rm.loadFromStore();
  35. RelationInfo a;
  36. a.name = "default:exec_wf";
  37. a.child = "default:executions";
  38. a.childField = "workflowId";
  39. a.parent = "default:workflows";
  40. std::string err;
  41. check(rm.createRelation(a, err), "created in default");
  42. RelationInfo b = a; // SAME bare name, different project
  43. b.name = "acme:exec_wf";
  44. b.child = "acme:executions";
  45. b.parent = "acme:workflows";
  46. check(rm.createRelation(b, err), "the same name in another project is allowed");
  47. check(rm.listRelations("default").size() == 1, "listing is project-filtered");
  48. check(rm.listRelations().size() == 2, "empty project lists everything");
  49. RelationManager fresh(f.store); // restart
  50. fresh.loadFromStore();
  51. check(fresh.getRelation("default:exec_wf").has_value(), "survives reload");
  52. check(fresh.getRelation("acme:exec_wf").has_value(), "both survive");
  53. }
  54. void test_cross_project_relation_is_refused() {
  55. Fixture f;
  56. RelationManager rm(f.store);
  57. RelationInfo r;
  58. r.name = "default:bad";
  59. r.child = "default:executions";
  60. r.childField = "workflowId";
  61. r.parent = "acme:workflows"; // different env - no txn spans two
  62. std::string err;
  63. check(!rm.createRelation(r, err), "a cross-project relation is refused");
  64. check(err.find("project") != std::string::npos, "and says why");
  65. }
  66. void test_more_than_one_page_of_relations_loads() {
  67. Fixture f;
  68. RelationManager rm(f.store);
  69. for (int i = 0; i < 250; ++i) { // Query::limit defaults to 100
  70. RelationInfo r;
  71. char buf[32];
  72. std::snprintf(buf, sizeof(buf), "default:r%03d", i);
  73. r.name = buf;
  74. r.child = "default:c";
  75. r.childField = "p";
  76. r.parent = "default:p";
  77. std::string err;
  78. rm.createRelation(r, err);
  79. }
  80. RelationManager fresh(f.store);
  81. fresh.loadFromStore();
  82. check(fresh.listRelations().size() == 250,
  83. "all 250 load - a bare Query would stop at 100, as it did for views, "
  84. "policies and collection configs");
  85. }
  86. // v2.11.0 T13 round 2 (review finding 3) — createRelation() refuses a
  87. // cross-project declaration (test_cross_project_relation_is_refused,
  88. // above), but loadFromStore() is the OTHER way a RelationInfo enters the
  89. // cache and did not re-check it. A legacy or hand-written `_relations`
  90. // document naming a cross-project parent must be skipped at load time too -
  91. // arming it would resolve the bare parent name inside the CHILD's own
  92. // project env (validate_on_write/RelationRef only ever resolve `parent`
  93. // against the child's project), silently checking the wrong collection.
  94. void test_load_skips_a_cross_project_relation_written_by_hand() {
  95. Fixture f;
  96. // Bypass createRelation()'s own guard entirely - write the raw document
  97. // straight into `_relations`, the way a legacy record or a hand-edited
  98. // one would exist on disk. `parent` names a DIFFERENT project than
  99. // `child`, which createRelation() would refuse today.
  100. nlohmann::json bad = {
  101. {"name", "default:bad_cross"},
  102. {"child", "default:executions"},
  103. {"child_field", "workflowId"},
  104. {"parent", "acme:workflows"},
  105. {"on_delete", "restrict"},
  106. {"validate_on_write", true},
  107. {"created_at", 0},
  108. {"updated_at", 0},
  109. };
  110. Document d;
  111. d.id = "default:bad_cross";
  112. d.collection = RelationManager::SYSTEM_COLLECTION;
  113. d.set_data(bad);
  114. f.store.insert(RelationManager::SYSTEM_COLLECTION, d);
  115. // A well-formed, same-project relation alongside it, to confirm one bad
  116. // record does not stop the rest of the load.
  117. RelationInfo good;
  118. good.name = "default:exec_wf";
  119. good.child = "default:executions";
  120. good.childField = "ownerId";
  121. good.parent = "default:users";
  122. {
  123. RelationManager rm(f.store);
  124. rm.loadFromStore();
  125. std::string err;
  126. check(rm.createRelation(good, err), "the well-formed sibling declares fine");
  127. }
  128. RelationManager fresh(f.store);
  129. fresh.loadFromStore();
  130. check(!fresh.getRelation("default:bad_cross").has_value(),
  131. "the cross-project relation was skipped, not armed with the wrong parent");
  132. check(fresh.getRelation("default:exec_wf").has_value(),
  133. "the well-formed sibling still loaded - one bad record did not stop the rest");
  134. }
  135. } // namespace
  136. // =========================================================================
  137. // v2.11.0 final review, finding 8 — the bare-vs-qualified bug class, made
  138. // unrepresentable at the RelationManager boundary rather than spot-fixed at
  139. // the caller.
  140. //
  141. // The live bug: armRelationsForChild() looked relations up under the caller's
  142. // RAW string while boot arming used the canonical "<project>:<collection>",
  143. // and set_relations() keys the storage map by the BARE name either way. So a
  144. // raw-gRPC caller naming "executions" instead of "default:executions" found
  145. // zero relations, and set_relations(bare, {}) then ERASED the entry the
  146. // canonical arming had filled - disarming both the reverse index and
  147. // validate_on_write for the life of the process, logged only as "re-armed 0
  148. // relation(s)", and silently repaired by a restart.
  149. //
  150. // This is the third occurrence of the class (v2.4.2 lost every view for two
  151. // releases, v2.4.5 gave every collection a phantom twin), so the fix is at the
  152. // one funnel every entry point already goes through.
  153. void test_bare_and_qualified_names_are_the_same_relation() {
  154. Fixture f;
  155. RelationManager rm(f.store);
  156. rm.loadFromStore();
  157. // Declared with an UNQUALIFIED name and unqualified endpoints, exactly as a
  158. // raw-gRPC caller (or a hand-written migration) would.
  159. RelationInfo bare;
  160. bare.name = "exec_wf";
  161. bare.child = "executions";
  162. bare.childField = "workflowId";
  163. bare.parent = "workflows";
  164. std::string err;
  165. check(rm.createRelation(bare, err), "a bare-named relation is accepted");
  166. // It is STORED canonically, so everything downstream sees one form.
  167. auto viaBare = rm.getRelation("exec_wf");
  168. check(viaBare.has_value(), "found under the bare name the caller used");
  169. check(viaBare && viaBare->name == "default:exec_wf",
  170. "but it reports its CANONICAL name - the declaration was normalised, "
  171. "not stored verbatim");
  172. check(viaBare && viaBare->child == "default:executions", "child canonicalised too");
  173. check(viaBare && viaBare->parent == "default:workflows", "parent canonicalised too");
  174. auto viaQualified = rm.getRelation("default:exec_wf");
  175. check(viaQualified.has_value(), "and found under the qualified name as well");
  176. // A second declaration under the OTHER spelling is a DUPLICATE, not a new
  177. // relation. Before the fix this created a second entry that armed the same
  178. // bare collection and clobbered the first.
  179. RelationInfo qualified = bare;
  180. qualified.name = "default:exec_wf";
  181. qualified.child = "default:executions";
  182. qualified.parent = "default:workflows";
  183. check(!rm.createRelation(qualified, err),
  184. "declaring the qualified spelling of an existing bare relation is refused "
  185. "as a duplicate");
  186. check(err.find("already exists") != std::string::npos, "and says why");
  187. check(rm.listRelations().size() == 1, "still exactly one relation, not two");
  188. // THE LOOKUP THAT WAS BROKEN: armRelationsForChild's, and Delete's.
  189. check(rm.relationsWithChild("executions").size() == 1,
  190. "relationsWithChild finds it under the BARE collection name - this is "
  191. "the lookup that returned zero and caused the disarm");
  192. check(rm.relationsWithChild("default:executions").size() == 1,
  193. "and under the qualified one");
  194. check(rm.relationsWithParent("workflows").size() == 1,
  195. "relationsWithParent likewise - a bare name here would report 'nobody's "
  196. "parent' and permit every delete");
  197. check(rm.relationsWithParent("default:workflows").size() == 1, "and qualified");
  198. // Cross-project isolation is not weakened by canonicalisation.
  199. check(rm.relationsWithChild("acme:executions").empty(),
  200. "another project's same-named collection still matches nothing");
  201. // Dropping by either spelling works, and actually removes the stored row.
  202. check(rm.dropRelation("exec_wf", err), "droppable by the bare name");
  203. check(!rm.getRelation("default:exec_wf").has_value(), "gone from the cache");
  204. RelationManager reloaded(f.store);
  205. reloaded.loadFromStore();
  206. check(reloaded.listRelations().empty(),
  207. "and gone from the store - dropping by the bare name really removed the "
  208. "canonical document, it did not just clear the cache");
  209. }
  210. // finding 8, the migration half: a record already stored under a non-canonical
  211. // document id is re-keyed on load, in the store as well as in the cache.
  212. // Without the store half, dropRelation() (which removes by canonical name)
  213. // would leave the row behind and the relation would come back on the next boot.
  214. void test_load_rekeys_a_legacy_bare_named_record() {
  215. Fixture f;
  216. // Hand-write a record the way a pre-fix createRelation would have stored it.
  217. Document d;
  218. d.id = "legacy_rel";
  219. d.set_data(nlohmann::json{
  220. {"name", "legacy_rel"},
  221. {"child", "executions"},
  222. {"child_field", "workflowId"},
  223. {"parent", "workflows"},
  224. {"on_delete", "restrict"},
  225. {"validate_on_write", false}});
  226. f.store.createCollection(RelationManager::SYSTEM_COLLECTION, CollectionOptions{});
  227. f.store.upsert(RelationManager::SYSTEM_COLLECTION, d);
  228. RelationManager rm(f.store);
  229. rm.loadFromStore();
  230. auto got = rm.getRelation("default:legacy_rel");
  231. check(got.has_value(), "the legacy record loaded");
  232. check(got && got->name == "default:legacy_rel", "under its canonical name");
  233. // The STORE was re-keyed, not just the cache.
  234. check(!f.store.get(RelationManager::SYSTEM_COLLECTION, "legacy_rel").has_value(),
  235. "the old bare-keyed document is gone");
  236. check(f.store.get(RelationManager::SYSTEM_COLLECTION, "default:legacy_rel").has_value(),
  237. "and a canonically-keyed one exists - so dropRelation(), which removes "
  238. "by the canonical name, can actually remove it");
  239. std::string err;
  240. check(rm.dropRelation("default:legacy_rel", err), "and it drops");
  241. RelationManager reloaded(f.store);
  242. reloaded.loadFromStore();
  243. check(reloaded.listRelations().empty(), "staying dropped across a reload");
  244. }
  245. // finding 9 — an unrecognised on_delete must be REFUSED, not coerced to
  246. // Restrict. There used to be two copies of the parser (one in
  247. // database_grpc_impl.cpp, one here) and both coerced silently. That failed safe
  248. // while cascade/set_null were inert; T12 made them destructive in the other
  249. // direction, so an operator who typed "Cascade" was told the relation was
  250. // created and believed cascade was armed while restrict was.
  251. void test_on_delete_is_validated_not_coerced() {
  252. check(parseOnDelete("restrict").has_value(), "restrict parses");
  253. check(parseOnDelete("cascade") == OnDelete::Cascade, "cascade parses");
  254. check(parseOnDelete("set_null") == OnDelete::SetNull, "set_null parses");
  255. check(parseOnDelete("no_action") == OnDelete::NoAction, "no_action parses");
  256. // The typo cases that used to become Restrict silently.
  257. check(!parseOnDelete("Cascade").has_value(), "'Cascade' is REFUSED, not coerced");
  258. check(!parseOnDelete("CASCADE").has_value(), "'CASCADE' is refused");
  259. check(!parseOnDelete("cascde").has_value(), "a misspelling is refused");
  260. check(!parseOnDelete("setnull").has_value(), "'setnull' is refused");
  261. check(!parseOnDelete("").has_value(),
  262. "and the empty string is refused HERE - the RPC layer, not the parser, "
  263. "is what maps absent to the documented default");
  264. // Round-trips through the one renderer, so the two directions cannot drift.
  265. for (auto v : {OnDelete::Restrict, OnDelete::Cascade, OnDelete::SetNull,
  266. OnDelete::NoAction}) {
  267. check(parseOnDelete(onDeleteToString(v)) == v,
  268. "onDeleteToString round-trips through parseOnDelete");
  269. }
  270. // A persisted record carrying a bad value falls back to Restrict (the safe
  271. // direction: over-restricting refuses deletes, it never performs an
  272. // unintended destructive one) rather than being dropped, which would remove
  273. // protection entirely.
  274. Fixture f;
  275. Document d;
  276. d.id = "default:bad_od";
  277. d.set_data(nlohmann::json{
  278. {"name", "default:bad_od"},
  279. {"child", "default:executions"},
  280. {"child_field", "workflowId"},
  281. {"parent", "default:workflows"},
  282. {"on_delete", "Cascade"}});
  283. f.store.createCollection(RelationManager::SYSTEM_COLLECTION, CollectionOptions{});
  284. f.store.upsert(RelationManager::SYSTEM_COLLECTION, d);
  285. RelationManager rm(f.store);
  286. rm.loadFromStore();
  287. auto got = rm.getRelation("default:bad_od");
  288. check(got.has_value(),
  289. "a persisted record with a bad on_delete is still LOADED - dropping it "
  290. "would silently remove protection");
  291. check(got && got->onDelete == OnDelete::Restrict,
  292. "and it falls back to restrict, the non-destructive direction");
  293. }
  294. int main() {
  295. std::cout << "=== test_relation_manager ===\n";
  296. test_relations_are_project_scoped_and_survive_reload();
  297. test_cross_project_relation_is_refused();
  298. test_more_than_one_page_of_relations_loads();
  299. test_load_skips_a_cross_project_relation_written_by_hand();
  300. test_bare_and_qualified_names_are_the_same_relation();
  301. test_load_rekeys_a_legacy_bare_named_record();
  302. test_on_delete_is_validated_not_coerced();
  303. std::cout << "passed: " << g_pass << ", failed: " << g_fail << "\n";
  304. return g_fail == 0 ? 0 : 1;
  305. }