test_relation_manager.cpp 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621
  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 <filesystem>
  14. #include <fstream>
  15. #include <unistd.h>
  16. #include "document.hpp"
  17. #include "memory_store.hpp"
  18. #include "migrations/migration_runner.hpp"
  19. #include "relations/relation_manager.hpp"
  20. #include "views/view_manager.hpp"
  21. using namespace smartbotic::database;
  22. namespace {
  23. int g_pass = 0;
  24. int g_fail = 0;
  25. void check(bool cond, const std::string& msg) {
  26. if (cond) { ++g_pass; }
  27. else { ++g_fail; std::cerr << "FAIL: " << msg << "\n"; }
  28. }
  29. struct Fixture {
  30. MemoryStore store;
  31. Fixture() : store(MemoryStore::Config{}) {
  32. store.start();
  33. }
  34. ~Fixture() { store.stop(); }
  35. };
  36. void test_relations_are_project_scoped_and_survive_reload() {
  37. Fixture f; // MemoryStore, started
  38. RelationManager rm(f.store);
  39. rm.loadFromStore();
  40. RelationInfo a;
  41. a.name = "default:exec_wf";
  42. a.child = "default:executions";
  43. a.childField = "workflowId";
  44. a.parent = "default:workflows";
  45. std::string err;
  46. check(rm.createRelation(a, err), "created in default");
  47. RelationInfo b = a; // SAME bare name, different project
  48. b.name = "acme:exec_wf";
  49. b.child = "acme:executions";
  50. b.parent = "acme:workflows";
  51. check(rm.createRelation(b, err), "the same name in another project is allowed");
  52. check(rm.listRelations("default").size() == 1, "listing is project-filtered");
  53. check(rm.listRelations().size() == 2, "empty project lists everything");
  54. RelationManager fresh(f.store); // restart
  55. fresh.loadFromStore();
  56. check(fresh.getRelation("default:exec_wf").has_value(), "survives reload");
  57. check(fresh.getRelation("acme:exec_wf").has_value(), "both survive");
  58. }
  59. void test_cross_project_relation_is_refused() {
  60. Fixture f;
  61. RelationManager rm(f.store);
  62. RelationInfo r;
  63. r.name = "default:bad";
  64. r.child = "default:executions";
  65. r.childField = "workflowId";
  66. r.parent = "acme:workflows"; // different env - no txn spans two
  67. std::string err;
  68. check(!rm.createRelation(r, err), "a cross-project relation is refused");
  69. check(err.find("project") != std::string::npos, "and says why");
  70. }
  71. void test_more_than_one_page_of_relations_loads() {
  72. Fixture f;
  73. RelationManager rm(f.store);
  74. for (int i = 0; i < 250; ++i) { // Query::limit defaults to 100
  75. RelationInfo r;
  76. char buf[32];
  77. std::snprintf(buf, sizeof(buf), "default:r%03d", i);
  78. r.name = buf;
  79. r.child = "default:c";
  80. r.childField = "p";
  81. r.parent = "default:p";
  82. std::string err;
  83. rm.createRelation(r, err);
  84. }
  85. RelationManager fresh(f.store);
  86. fresh.loadFromStore();
  87. check(fresh.listRelations().size() == 250,
  88. "all 250 load - a bare Query would stop at 100, as it did for views, "
  89. "policies and collection configs");
  90. }
  91. // v2.11.0 T13 round 2 (review finding 3) — createRelation() refuses a
  92. // cross-project declaration (test_cross_project_relation_is_refused,
  93. // above), but loadFromStore() is the OTHER way a RelationInfo enters the
  94. // cache and did not re-check it. A legacy or hand-written `_relations`
  95. // document naming a cross-project parent must be skipped at load time too -
  96. // arming it would resolve the bare parent name inside the CHILD's own
  97. // project env (validate_on_write/RelationRef only ever resolve `parent`
  98. // against the child's project), silently checking the wrong collection.
  99. void test_load_skips_a_cross_project_relation_written_by_hand() {
  100. Fixture f;
  101. // Bypass createRelation()'s own guard entirely - write the raw document
  102. // straight into `_relations`, the way a legacy record or a hand-edited
  103. // one would exist on disk. `parent` names a DIFFERENT project than
  104. // `child`, which createRelation() would refuse today.
  105. nlohmann::json bad = {
  106. {"name", "default:bad_cross"},
  107. {"child", "default:executions"},
  108. {"child_field", "workflowId"},
  109. {"parent", "acme:workflows"},
  110. {"on_delete", "restrict"},
  111. {"validate_on_write", true},
  112. {"created_at", 0},
  113. {"updated_at", 0},
  114. };
  115. Document d;
  116. d.id = "default:bad_cross";
  117. d.collection = RelationManager::SYSTEM_COLLECTION;
  118. d.set_data(bad);
  119. f.store.insert(RelationManager::SYSTEM_COLLECTION, d);
  120. // A well-formed, same-project relation alongside it, to confirm one bad
  121. // record does not stop the rest of the load.
  122. RelationInfo good;
  123. good.name = "default:exec_wf";
  124. good.child = "default:executions";
  125. good.childField = "ownerId";
  126. good.parent = "default:users";
  127. {
  128. RelationManager rm(f.store);
  129. rm.loadFromStore();
  130. std::string err;
  131. check(rm.createRelation(good, err), "the well-formed sibling declares fine");
  132. }
  133. RelationManager fresh(f.store);
  134. fresh.loadFromStore();
  135. check(!fresh.getRelation("default:bad_cross").has_value(),
  136. "the cross-project relation was skipped, not armed with the wrong parent");
  137. check(fresh.getRelation("default:exec_wf").has_value(),
  138. "the well-formed sibling still loaded - one bad record did not stop the rest");
  139. }
  140. } // namespace
  141. // =========================================================================
  142. // v2.11.0 final review, finding 8 — the bare-vs-qualified bug class, made
  143. // unrepresentable at the RelationManager boundary rather than spot-fixed at
  144. // the caller.
  145. //
  146. // The live bug: armRelationsForChild() looked relations up under the caller's
  147. // RAW string while boot arming used the canonical "<project>:<collection>",
  148. // and set_relations() keys the storage map by the BARE name either way. So a
  149. // raw-gRPC caller naming "executions" instead of "default:executions" found
  150. // zero relations, and set_relations(bare, {}) then ERASED the entry the
  151. // canonical arming had filled - disarming both the reverse index and
  152. // validate_on_write for the life of the process, logged only as "re-armed 0
  153. // relation(s)", and silently repaired by a restart.
  154. //
  155. // This is the third occurrence of the class (v2.4.2 lost every view for two
  156. // releases, v2.4.5 gave every collection a phantom twin), so the fix is at the
  157. // one funnel every entry point already goes through.
  158. void test_bare_and_qualified_names_are_the_same_relation() {
  159. Fixture f;
  160. RelationManager rm(f.store);
  161. rm.loadFromStore();
  162. // Declared with an UNQUALIFIED name and unqualified endpoints, exactly as a
  163. // raw-gRPC caller (or a hand-written migration) would.
  164. RelationInfo bare;
  165. bare.name = "exec_wf";
  166. bare.child = "executions";
  167. bare.childField = "workflowId";
  168. bare.parent = "workflows";
  169. std::string err;
  170. check(rm.createRelation(bare, err), "a bare-named relation is accepted");
  171. // It is STORED canonically, so everything downstream sees one form.
  172. auto viaBare = rm.getRelation("exec_wf");
  173. check(viaBare.has_value(), "found under the bare name the caller used");
  174. check(viaBare && viaBare->name == "default:exec_wf",
  175. "but it reports its CANONICAL name - the declaration was normalised, "
  176. "not stored verbatim");
  177. check(viaBare && viaBare->child == "default:executions", "child canonicalised too");
  178. check(viaBare && viaBare->parent == "default:workflows", "parent canonicalised too");
  179. auto viaQualified = rm.getRelation("default:exec_wf");
  180. check(viaQualified.has_value(), "and found under the qualified name as well");
  181. // A second declaration under the OTHER spelling is a DUPLICATE, not a new
  182. // relation. Before the fix this created a second entry that armed the same
  183. // bare collection and clobbered the first.
  184. RelationInfo qualified = bare;
  185. qualified.name = "default:exec_wf";
  186. qualified.child = "default:executions";
  187. qualified.parent = "default:workflows";
  188. check(!rm.createRelation(qualified, err),
  189. "declaring the qualified spelling of an existing bare relation is refused "
  190. "as a duplicate");
  191. check(err.find("already exists") != std::string::npos, "and says why");
  192. check(rm.listRelations().size() == 1, "still exactly one relation, not two");
  193. // THE LOOKUP THAT WAS BROKEN: armRelationsForChild's, and Delete's.
  194. check(rm.relationsWithChild("executions").size() == 1,
  195. "relationsWithChild finds it under the BARE collection name - this is "
  196. "the lookup that returned zero and caused the disarm");
  197. check(rm.relationsWithChild("default:executions").size() == 1,
  198. "and under the qualified one");
  199. check(rm.relationsWithParent("workflows").size() == 1,
  200. "relationsWithParent likewise - a bare name here would report 'nobody's "
  201. "parent' and permit every delete");
  202. check(rm.relationsWithParent("default:workflows").size() == 1, "and qualified");
  203. // Cross-project isolation is not weakened by canonicalisation.
  204. check(rm.relationsWithChild("acme:executions").empty(),
  205. "another project's same-named collection still matches nothing");
  206. // Dropping by either spelling works, and actually removes the stored row.
  207. check(rm.dropRelation("exec_wf", err), "droppable by the bare name");
  208. check(!rm.getRelation("default:exec_wf").has_value(), "gone from the cache");
  209. RelationManager reloaded(f.store);
  210. reloaded.loadFromStore();
  211. check(reloaded.listRelations().empty(),
  212. "and gone from the store - dropping by the bare name really removed the "
  213. "canonical document, it did not just clear the cache");
  214. }
  215. // finding 8, the migration half: a record already stored under a non-canonical
  216. // document id is re-keyed on load, in the store as well as in the cache.
  217. // Without the store half, dropRelation() (which removes by canonical name)
  218. // would leave the row behind and the relation would come back on the next boot.
  219. void test_load_rekeys_a_legacy_bare_named_record() {
  220. Fixture f;
  221. // Hand-write a record the way a pre-fix createRelation would have stored it.
  222. Document d;
  223. d.id = "legacy_rel";
  224. d.set_data(nlohmann::json{
  225. {"name", "legacy_rel"},
  226. {"child", "executions"},
  227. {"child_field", "workflowId"},
  228. {"parent", "workflows"},
  229. {"on_delete", "restrict"},
  230. {"validate_on_write", false}});
  231. f.store.createCollection(RelationManager::SYSTEM_COLLECTION, CollectionOptions{});
  232. f.store.upsert(RelationManager::SYSTEM_COLLECTION, d);
  233. RelationManager rm(f.store);
  234. rm.loadFromStore();
  235. auto got = rm.getRelation("default:legacy_rel");
  236. check(got.has_value(), "the legacy record loaded");
  237. check(got && got->name == "default:legacy_rel", "under its canonical name");
  238. // The STORE was re-keyed, not just the cache.
  239. check(!f.store.get(RelationManager::SYSTEM_COLLECTION, "legacy_rel").has_value(),
  240. "the old bare-keyed document is gone");
  241. check(f.store.get(RelationManager::SYSTEM_COLLECTION, "default:legacy_rel").has_value(),
  242. "and a canonically-keyed one exists - so dropRelation(), which removes "
  243. "by the canonical name, can actually remove it");
  244. std::string err;
  245. check(rm.dropRelation("default:legacy_rel", err), "and it drops");
  246. RelationManager reloaded(f.store);
  247. reloaded.loadFromStore();
  248. check(reloaded.listRelations().empty(), "staying dropped across a reload");
  249. }
  250. // finding 9 — an unrecognised on_delete must be REFUSED, not coerced to
  251. // Restrict. There used to be two copies of the parser (one in
  252. // database_grpc_impl.cpp, one here) and both coerced silently. That failed safe
  253. // while cascade/set_null were inert; T12 made them destructive in the other
  254. // direction, so an operator who typed "Cascade" was told the relation was
  255. // created and believed cascade was armed while restrict was.
  256. void test_on_delete_is_validated_not_coerced() {
  257. check(parseOnDelete("restrict").has_value(), "restrict parses");
  258. check(parseOnDelete("cascade") == OnDelete::Cascade, "cascade parses");
  259. check(parseOnDelete("set_null") == OnDelete::SetNull, "set_null parses");
  260. check(parseOnDelete("no_action") == OnDelete::NoAction, "no_action parses");
  261. // The typo cases that used to become Restrict silently.
  262. check(!parseOnDelete("Cascade").has_value(), "'Cascade' is REFUSED, not coerced");
  263. check(!parseOnDelete("CASCADE").has_value(), "'CASCADE' is refused");
  264. check(!parseOnDelete("cascde").has_value(), "a misspelling is refused");
  265. check(!parseOnDelete("setnull").has_value(), "'setnull' is refused");
  266. check(!parseOnDelete("").has_value(),
  267. "and the empty string is refused HERE - the RPC layer, not the parser, "
  268. "is what maps absent to the documented default");
  269. // Round-trips through the one renderer, so the two directions cannot drift.
  270. for (auto v : {OnDelete::Restrict, OnDelete::Cascade, OnDelete::SetNull,
  271. OnDelete::NoAction}) {
  272. check(parseOnDelete(onDeleteToString(v)) == v,
  273. "onDeleteToString round-trips through parseOnDelete");
  274. }
  275. // A persisted record carrying a bad value falls back to Restrict (the safe
  276. // direction: over-restricting refuses deletes, it never performs an
  277. // unintended destructive one) rather than being dropped, which would remove
  278. // protection entirely.
  279. Fixture f;
  280. Document d;
  281. d.id = "default:bad_od";
  282. d.set_data(nlohmann::json{
  283. {"name", "default:bad_od"},
  284. {"child", "default:executions"},
  285. {"child_field", "workflowId"},
  286. {"parent", "default:workflows"},
  287. {"on_delete", "Cascade"}});
  288. f.store.createCollection(RelationManager::SYSTEM_COLLECTION, CollectionOptions{});
  289. f.store.upsert(RelationManager::SYSTEM_COLLECTION, d);
  290. RelationManager rm(f.store);
  291. rm.loadFromStore();
  292. auto got = rm.getRelation("default:bad_od");
  293. check(got.has_value(),
  294. "a persisted record with a bad on_delete is still LOADED - dropping it "
  295. "would silently remove protection");
  296. check(got && got->onDelete == OnDelete::Restrict,
  297. "and it falls back to restrict, the non-destructive direction");
  298. }
  299. // =========================================================================
  300. // v2.11.0 close-out — the `create_relation` MIGRATION OP.
  301. //
  302. // Declaring schema in migration files is how consumers ship views
  303. // (shadowman-cpp: /opt/shadowman/share/shadowman/migrations/json, callerai:
  304. // /etc/callerai/migrations), and until this op existed they could not declare a
  305. // relation at all. Modelled on create_view: same file shape, same idempotency,
  306. // and it goes through RelationManager::createRelation so the same-project rule
  307. // and the on_delete validation apply rather than being bypassed.
  308. // =========================================================================
  309. std::filesystem::path makeMigrationDir(const std::string& tag) {
  310. auto dir = std::filesystem::temp_directory_path() /
  311. ("mig-relation-" + tag + "-" + std::to_string(::getpid()));
  312. std::filesystem::remove_all(dir);
  313. std::filesystem::create_directories(dir);
  314. return dir;
  315. }
  316. void writeMigration(const std::filesystem::path& dir, const std::string& file,
  317. const std::string& body) {
  318. std::ofstream out(dir / file);
  319. out << body;
  320. }
  321. void test_create_relation_migration_op_declares_and_is_idempotent() {
  322. auto dir = makeMigrationDir("basic");
  323. writeMigration(dir, "001_relations.json", R"({
  324. "version": "001",
  325. "name": "declare_exec_wf",
  326. "operations": [
  327. {"type": "create_collection", "collection": "workflows"},
  328. {"type": "create_collection", "collection": "executions"},
  329. {"type": "create_relation",
  330. "name": "exec_wf",
  331. "child": "executions",
  332. "child_field": "workflowId",
  333. "parent": "workflows",
  334. "on_delete": "cascade",
  335. "validate_on_write": true}
  336. ]
  337. })");
  338. Fixture f;
  339. ViewManager vm(f.store);
  340. RelationManager rm(f.store);
  341. MigrationRunner::Config cfg;
  342. cfg.directory = dir;
  343. {
  344. MigrationRunner runner(f.store, vm, rm, cfg);
  345. check(runner.runMigrations(), "the migration ran");
  346. }
  347. // Bare names in a migration file qualify to `default:`, exactly as every
  348. // other collection name in a migration file does.
  349. auto got = rm.getRelation("default:exec_wf");
  350. check(got.has_value(), "the create_relation op DECLARED the relation");
  351. if (got) {
  352. check(got->child == "default:executions", "child is project-qualified");
  353. check(got->childField == "workflowId", "child_field carried through");
  354. check(got->parent == "default:workflows", "parent is project-qualified");
  355. check(got->onDelete == OnDelete::Cascade, "on_delete was parsed, not defaulted");
  356. check(got->validateOnWrite, "validate_on_write carried through");
  357. }
  358. // Idempotency has TWO layers and both matter. The runner skips an
  359. // already-applied migration file, so re-running is a no-op at that level;
  360. // the op itself must ALSO tolerate "already exists", which is what a
  361. // consumer re-shipping the same declaration under a new version number
  362. // hits.
  363. {
  364. MigrationRunner runner(f.store, vm, rm, cfg);
  365. check(runner.runMigrations(), "re-running the same migrations still succeeds");
  366. }
  367. writeMigration(dir, "002_again.json", R"({
  368. "version": "002",
  369. "name": "declare_exec_wf_again",
  370. "operations": [
  371. {"type": "create_relation",
  372. "name": "exec_wf", "child": "executions",
  373. "child_field": "workflowId", "parent": "workflows",
  374. "on_delete": "cascade"}
  375. ]
  376. })");
  377. {
  378. MigrationRunner runner(f.store, vm, rm, cfg);
  379. check(runner.runMigrations(),
  380. "a SECOND migration re-declaring the same relation succeeds - "
  381. "'already exists' is not a failure on replay");
  382. }
  383. check(rm.listRelations("default").size() == 1,
  384. "and it did not duplicate the declaration");
  385. std::filesystem::remove_all(dir);
  386. }
  387. void test_create_relation_migration_op_validates() {
  388. // A typo'd on_delete must FAIL the migration, not silently arm restrict.
  389. // The reason it matters more here than at the RPC: a typo in a file that
  390. // ships in a deb would otherwise be wrong on every install, forever.
  391. {
  392. auto dir = makeMigrationDir("typo");
  393. writeMigration(dir, "001_typo.json", R"({
  394. "version": "001", "name": "typo",
  395. "operations": [
  396. {"type": "create_relation", "name": "bad_rel", "child": "executions",
  397. "child_field": "workflowId", "parent": "workflows",
  398. "on_delete": "Cascade"}
  399. ]
  400. })");
  401. Fixture f;
  402. ViewManager vm(f.store);
  403. RelationManager rm(f.store);
  404. MigrationRunner::Config cfg;
  405. cfg.directory = dir;
  406. MigrationRunner runner(f.store, vm, rm, cfg);
  407. check(!runner.runMigrations(), "an unrecognised on_delete FAILS the migration");
  408. check(!rm.getRelation("default:bad_rel").has_value(),
  409. "and nothing was declared - not coerced to restrict");
  410. std::filesystem::remove_all(dir);
  411. }
  412. // A cross-project declaration must be refused by RelationManager, which is
  413. // the whole point of routing through createRelation rather than writing the
  414. // `_relations` record directly.
  415. {
  416. auto dir = makeMigrationDir("xproj");
  417. writeMigration(dir, "001_xproj.json", R"({
  418. "version": "001", "name": "xproj",
  419. "operations": [
  420. {"type": "create_relation", "name": "a:rel", "child": "a:executions",
  421. "child_field": "workflowId", "parent": "b:workflows"}
  422. ]
  423. })");
  424. Fixture f;
  425. ViewManager vm(f.store);
  426. RelationManager rm(f.store);
  427. MigrationRunner::Config cfg;
  428. cfg.directory = dir;
  429. MigrationRunner runner(f.store, vm, rm, cfg);
  430. check(!runner.runMigrations(),
  431. "a cross-project relation is refused through the migration op too");
  432. check(!rm.getRelation("a:rel").has_value(), "and nothing was declared");
  433. std::filesystem::remove_all(dir);
  434. }
  435. // Missing required fields fail rather than declaring a half-relation.
  436. {
  437. auto dir = makeMigrationDir("missing");
  438. writeMigration(dir, "001_missing.json", R"({
  439. "version": "001", "name": "missing",
  440. "operations": [
  441. {"type": "create_relation", "name": "half_rel", "child": "executions"}
  442. ]
  443. })");
  444. Fixture f;
  445. ViewManager vm(f.store);
  446. RelationManager rm(f.store);
  447. MigrationRunner::Config cfg;
  448. cfg.directory = dir;
  449. MigrationRunner runner(f.store, vm, rm, cfg);
  450. check(!runner.runMigrations(), "a create_relation missing child_field/parent fails");
  451. check(!rm.getRelation("default:half_rel").has_value(), "and declares nothing");
  452. std::filesystem::remove_all(dir);
  453. }
  454. // Absent on_delete means the documented default, and is NOT an error.
  455. {
  456. auto dir = makeMigrationDir("default-od");
  457. writeMigration(dir, "001_default.json", R"({
  458. "version": "001", "name": "defaulted",
  459. "operations": [
  460. {"type": "create_relation", "name": "def_rel", "child": "executions",
  461. "child_field": "workflowId", "parent": "workflows"}
  462. ]
  463. })");
  464. Fixture f;
  465. ViewManager vm(f.store);
  466. RelationManager rm(f.store);
  467. MigrationRunner::Config cfg;
  468. cfg.directory = dir;
  469. MigrationRunner runner(f.store, vm, rm, cfg);
  470. check(runner.runMigrations(), "an absent on_delete is accepted");
  471. auto got = rm.getRelation("default:def_rel");
  472. check(got.has_value(), "and the relation is declared");
  473. check(got && got->onDelete == OnDelete::Restrict, "with restrict, the documented default");
  474. check(got && !got->validateOnWrite, "and validate_on_write defaulting to false");
  475. std::filesystem::remove_all(dir);
  476. }
  477. }
  478. // v2.11.1 — an operator typed an UNQUALIFIED relation name with a qualified
  479. // child and parent and was told "relation, child and parent must be in one
  480. // project", which named the two arguments that were correct. A bare name
  481. // resolves to `default` like every other bare name, so it could never match a
  482. // child living anywhere else - and since a cross-project relation is impossible,
  483. // there is exactly one project the name could have meant.
  484. void test_unqualified_relation_name_adopts_its_child_project() {
  485. Fixture f;
  486. RelationManager rm(f.store);
  487. RelationInfo r;
  488. r.name = "exec_wf"; // bare - would resolve to `default`
  489. r.child = "acme:executions";
  490. r.childField = "workflowId";
  491. r.parent = "acme:workflows";
  492. std::string err;
  493. check(rm.createRelation(r, err), "a bare name with an acme child is accepted: " + err);
  494. check(rm.getRelation("acme:exec_wf").has_value(),
  495. "and it is stored in acme, not default");
  496. check(!rm.getRelation("default:exec_wf").has_value(),
  497. "nothing was created in default");
  498. check(rm.listRelations("acme").size() == 1, "acme lists it");
  499. check(rm.listRelations("default").empty(), "default does not");
  500. // Reload, because adoption must happen in the persisted record and not only
  501. // in the in-memory cache - otherwise a restart would lose the relation.
  502. RelationManager fresh(f.store);
  503. fresh.loadFromStore();
  504. check(fresh.getRelation("acme:exec_wf").has_value(), "survives a reload in acme");
  505. }
  506. // An EXPLICITLY qualified name that disagrees is a real statement of intent that
  507. // cannot be honoured, so it stays refused - and now the message names the
  508. // argument that is actually wrong plus the project it resolved to.
  509. void test_explicitly_wrong_project_on_the_name_is_still_refused_and_says_which() {
  510. Fixture f;
  511. RelationManager rm(f.store);
  512. RelationInfo r;
  513. r.name = "default:exec_wf"; // deliberately the wrong project
  514. r.child = "acme:executions";
  515. r.childField = "workflowId";
  516. r.parent = "acme:workflows";
  517. std::string err;
  518. check(!rm.createRelation(r, err), "a qualified name in the wrong project is refused");
  519. check(err.find("relation name") != std::string::npos,
  520. "the message blames the NAME, not the child or parent: " + err);
  521. check(err.find("acme:exec_wf") != std::string::npos,
  522. "and spells out the name that would have worked: " + err);
  523. check(!rm.getRelation("acme:exec_wf").has_value(), "nothing was created");
  524. }
  525. // The child/parent mismatch message must name both sides and their projects. It
  526. // previously listed all three arguments and said which project none of them was
  527. // in, which is what sent an operator looking at the wrong argument.
  528. void test_child_parent_mismatch_names_both_sides() {
  529. Fixture f;
  530. RelationManager rm(f.store);
  531. RelationInfo r;
  532. r.name = "acme:bad";
  533. r.child = "acme:executions";
  534. r.childField = "workflowId";
  535. r.parent = "other:workflows";
  536. std::string err;
  537. check(!rm.createRelation(r, err), "refused");
  538. check(err.find("acme:executions") != std::string::npos &&
  539. err.find("other:workflows") != std::string::npos,
  540. "both offending arguments are named: " + err);
  541. check(err.find("'acme'") != std::string::npos && err.find("'other'") != std::string::npos,
  542. "with the projects they resolved to: " + err);
  543. }
  544. int main() {
  545. std::cout << "=== test_relation_manager ===\n";
  546. test_relations_are_project_scoped_and_survive_reload();
  547. test_cross_project_relation_is_refused();
  548. test_more_than_one_page_of_relations_loads();
  549. test_load_skips_a_cross_project_relation_written_by_hand();
  550. test_bare_and_qualified_names_are_the_same_relation();
  551. test_load_rekeys_a_legacy_bare_named_record();
  552. test_on_delete_is_validated_not_coerced();
  553. test_create_relation_migration_op_declares_and_is_idempotent();
  554. test_create_relation_migration_op_validates();
  555. test_unqualified_relation_name_adopts_its_child_project();
  556. test_explicitly_wrong_project_on_the_name_is_still_refused_and_says_which();
  557. test_child_parent_mismatch_names_both_sides();
  558. std::cout << "passed: " << g_pass << ", failed: " << g_fail << "\n";
  559. return g_fail == 0 ? 0 : 1;
  560. }