Browse Source

feat(relations): delete-impact, enforcement toggle and 409 on blocked delete

Adds GET .../documents/{id}/delete-impact (per-collection read) and
PUT .../collections/{c}/relations-enforced (admin) to relations.cpp, and
turns a relation-blocked document delete into a 409 with slug
relation_restricted and error.details.impacts, using the 3-arg remove()
overload to surface the DB's refusal message.

relations_enforced is read on the collection being deleted FROM (the
parent side of the relation), not the child - confirmed against the DB
service source and empirically. The enforcement-toggle test targets the
parent collection accordingly, with a comment recording the rule.
fszontagh 1 tháng trước cách đây
mục cha
commit
306205be05
4 tập tin đã thay đổi với 131 bổ sung và 2 xóa
  1. 12 1
      src/handlers/documents.cpp
  2. 48 1
      src/handlers/relations.cpp
  3. 6 0
      src/server.hpp
  4. 65 0
      tests/test_api_integration.cpp

+ 12 - 1
src/handlers/documents.cpp

@@ -89,7 +89,18 @@ void registerDocumentRoutes(ApiServer& s) {
 
     svr.Delete(R"(/api/v1/projects/([^/]+)/collections/([^/]+)/documents/([^/]+))", [d](const httplib::Request& req, httplib::Response& res) {
         std::string c = scoped(d, req, 1, 2, KeyOp::Delete); std::string id = req.matches[3];
-        if (!d->db.client().remove(c, id)) throw ApiError(ErrCode::NotFound, "not_found", "no such document");
+        std::string project = req.matches[1];
+        std::string err;
+        if (!d->db.client().remove(c, id, err)) {
+            // A restrict relation is the one refusal a caller can act on, so it
+            // gets 409 plus the impact list rather than a generic failure.
+            auto why = d->db.client().describeDelete(c, id);
+            if (why.success && why.wouldBeBlocked)
+                throw ApiError(ErrCode::Conflict, "relation_restricted",
+                               err.empty() ? "delete blocked by a relation" : err,
+                               {{"impacts", impactsJson(project, why)}});
+            throw ApiError(ErrCode::NotFound, "not_found", "no such document");
+        }
         sendJson(res, 200, {{"deleted", id}});
     });
 }

+ 48 - 1
src/handlers/relations.cpp

@@ -3,7 +3,6 @@
 #include "server.hpp"
 
 namespace svapi {
-namespace {
 
 // Strip a leading "project:" so the API never leaks qualified names outward.
 std::string unqualify(const std::string& project, const std::string& name) {
@@ -11,6 +10,8 @@ std::string unqualify(const std::string& project, const std::string& name) {
     return name.rfind(pfx, 0) == 0 ? name.substr(pfx.size()) : name;
 }
 
+namespace {
+
 nlohmann::json relJson(const std::string& project,
                        const smartbotic::database::Client::RelationDefinition& r) {
     return {{"name",        unqualify(project, r.name)},
@@ -25,6 +26,24 @@ nlohmann::json relJson(const std::string& project,
 
 } // namespace
 
+// Shared with handlers/documents.cpp: renders describeDelete impacts as JSON.
+nlohmann::json impactsJson(const std::string& project,
+                           const smartbotic::database::Client::DescribeDeleteResult& r) {
+    nlohmann::json arr = nlohmann::json::array();
+    for (const auto& i : r.impacts) {
+        nlohmann::json ids = nlohmann::json::array();
+        for (const auto& s : i.sampleChildIds) ids.push_back(s);
+        arr.push_back({{"relation",         unqualify(project, i.relation)},
+                       {"child_collection", unqualify(project, i.childCollection)},
+                       {"child_field",      i.childField},
+                       {"on_delete",        i.onDelete},
+                       {"child_count",      i.childCount},
+                       {"sample_child_ids", ids},
+                       {"blocks",           i.blocks}});
+    }
+    return arr;
+}
+
 void registerRelationRoutes(ApiServer& s) {
     auto& svr = s.raw(); ServerDeps* d = &s.deps();
 
@@ -88,6 +107,34 @@ void registerRelationRoutes(ApiServer& s) {
             throw ApiError(ErrCode::NotFound, "not_found", "relation not found");
         sendJson(res, 200, {{"dropped", name}});
     });
+
+    svr.Get(R"(/api/v1/projects/([^/]+)/collections/([^/]+)/documents/([^/]+)/delete-impact)",
+            [d](const httplib::Request& req, httplib::Response& res) {
+        ApiKey k = requireKey(*d, req);
+        std::string project = req.matches[1], coll = req.matches[2], id = req.matches[3];
+        // Read-only: changes nothing, but child counts and sample ids are facts
+        // about data, so it follows the collection's read grant.
+        requireCapability(*d, k, req, project, coll, KeyOp::Read);
+        auto r = d->db.client().describeDelete(qualify(project, coll), id);
+        if (!r.success) throw ApiError(ErrCode::NotFound, "not_found",
+                                       r.error.empty() ? "document not found" : r.error);
+        sendJson(res, 200, {{"would_be_blocked", r.wouldBeBlocked},
+                            {"impacts", impactsJson(project, r)}});
+    });
+
+    svr.Put(R"(/api/v1/projects/([^/]+)/collections/([^/]+)/relations-enforced)",
+            [d](const httplib::Request& req, httplib::Response& res) {
+        ApiKey k = requireKey(*d, req);
+        std::string project = req.matches[1], coll = req.matches[2];
+        requireAdmin(k); requireProjectAccess(k, project);
+        auto body = bodyJson(req);
+        if (!body.is_object() || !body.contains("enforced") || !body["enforced"].is_boolean())
+            throw ApiError(ErrCode::Unprocessable, "validation", "enforced (boolean) is required");
+        const bool enforced = body["enforced"].get<bool>();
+        if (!d->db.client().setRelationsEnforced(qualify(project, coll), enforced))
+            throw ApiError(ErrCode::Unavailable, "db_error", "failed to set relations_enforced");
+        sendJson(res, 200, {{"collection", coll}, {"enforced", enforced}});
+    });
 }
 
 } // namespace svapi

+ 6 - 0
src/server.hpp

@@ -48,6 +48,12 @@ void   requireProjectManage(const ApiKey& k, const std::string& project);  // th
 void   requireCapability(ServerDeps& d, const ApiKey& k, const httplib::Request& req,
                          const std::string& project, const std::string& collection, KeyOp op);
 
+// Shared with handlers/relations.cpp; documents.cpp needs them for the 409 payload
+// on a relation-blocked delete.
+std::string unqualify(const std::string& project, const std::string& name);
+nlohmann::json impactsJson(const std::string& project,
+                           const smartbotic::database::Client::DescribeDeleteResult& r);
+
 // Route registration (handlers/*.cpp).
 void registerMetaRoutes(ApiServer&);
 void registerWebuiAuthRoutes(ApiServer&);

+ 65 - 0
tests/test_api_integration.cpp

@@ -1094,3 +1094,68 @@ TEST_F(ApiFixture, RelationRejectsBadOnDelete) {
         {"parent","c"}, {"on_delete","explode"}}.dump(), "application/json");
     ASSERT_TRUE(bad); EXPECT_EQ(bad->status, 422);
 }
+
+TEST_F(ApiFixture, DeleteBlockedByRelationReturns409WithImpacts) {
+    auto c = admin();
+    std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","inv"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","cust"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+    std::string rel = "/api/v1/projects/" + project_ + "/relations";
+    ASSERT_EQ(c.Post(rel.c_str(), nlohmann::json{
+        {"name","inv_cust"}, {"child","inv"}, {"child_field","cust_id"},
+        {"parent","cust"}, {"on_delete","restrict"}}.dump(), "application/json")->status, 201);
+
+    auto p = c.Post((base + "/cust/documents").c_str(),
+                    nlohmann::json{{"n","acme"}}.dump(), "application/json");
+    ASSERT_EQ(p->status, 201);
+    std::string pid = nlohmann::json::parse(p->body)["id"];
+    ASSERT_EQ(c.Post((base + "/inv/documents").c_str(),
+                     nlohmann::json{{"cust_id", pid}}.dump(), "application/json")->status, 201);
+
+    auto impact = c.Get((base + "/cust/documents/" + pid + "/delete-impact").c_str());
+    ASSERT_TRUE(impact); EXPECT_EQ(impact->status, 200);
+    auto impactJson = nlohmann::json::parse(impact->body);
+    EXPECT_TRUE(impactJson["would_be_blocked"].get<bool>());
+    EXPECT_GE(impactJson["impacts"].size(), 1u);
+
+    auto del = c.Delete((base + "/cust/documents/" + pid).c_str());
+    ASSERT_TRUE(del); EXPECT_EQ(del->status, 409);
+    auto delJson = nlohmann::json::parse(del->body);
+    EXPECT_EQ(delJson["error"]["code"], "relation_restricted");
+    EXPECT_GE(delJson["error"]["details"]["impacts"].size(), 1u);
+}
+
+// relations_enforced is read on the collection being deleted FROM (the
+// PARENT side of the relation) — Delete() checks
+// config_manager_.configFor(request->collection()), i.e. the parent's own
+// flag. Disabling it on the CHILD collection has no effect on deletes of
+// the parent's documents: the child's relations_enforced only governs the
+// child's own reverse-index arming / validate_on_write, a separate
+// mechanism. So the toggle here targets "cust2" (the parent), not "inv2"
+// (the child) — this is deliberately non-obvious and every API consumer
+// will trip on it otherwise (see task-8-report.md for the source trace).
+TEST_F(ApiFixture, RelationsEnforcedOnParentPermitsDelete) {
+    auto c = admin();
+    std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","inv2"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","cust2"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+    std::string rel = "/api/v1/projects/" + project_ + "/relations";
+    ASSERT_EQ(c.Post(rel.c_str(), nlohmann::json{
+        {"name","inv2_cust2"}, {"child","inv2"}, {"child_field","cust_id"},
+        {"parent","cust2"}, {"on_delete","restrict"}}.dump(), "application/json")->status, 201);
+
+    auto p = c.Post((base + "/cust2/documents").c_str(),
+                    nlohmann::json{{"n","x"}}.dump(), "application/json");
+    std::string pid = nlohmann::json::parse(p->body)["id"];
+    ASSERT_EQ(c.Post((base + "/inv2/documents").c_str(),
+                     nlohmann::json{{"cust_id", pid}}.dump(), "application/json")->status, 201);
+
+    auto off = c.Put((base + "/cust2/relations-enforced").c_str(),
+                     nlohmann::json{{"enforced", false}}.dump(), "application/json");
+    ASSERT_TRUE(off); EXPECT_EQ(off->status, 200);
+    EXPECT_EQ(c.Delete((base + "/cust2/documents/" + pid).c_str())->status, 200);
+}