Kaynağa Gözat

feat(admin): server read-only lock endpoint

Add GET/PUT /api/v1/admin/readonly (admin-only) to settings.cpp,
backed by the DB client's authoritative getReadOnlyStatus()/setReadOnly()
RPCs rather than a locally-tracked flag. GET returns {readonly, scope,
reason?} - reason is included when non-empty since the DB can also enter
read-only on its own after a degraded/failed recovery, not only via this
endpoint. PUT validates the boolean body, calls setReadOnly(), throws
db_error/503 on failure, and re-reads via getReadOnlyStatus() for the
response instead of echoing the requested value.

No ServerDeps changes needed: StatsInfo has no read-only field (as
suspected), but Client::getReadOnlyStatus() (client.hpp:1119) is a real
getter that makes the server state authoritative, so no local tracking
flag, atomic member, or restart-caveat wording is required.
fszontagh 1 ay önce
ebeveyn
işleme
68ab63f862
2 değiştirilmiş dosya ile 81 ekleme ve 0 silme
  1. 26 0
      src/handlers/settings.cpp
  2. 55 0
      tests/test_api_integration.cpp

+ 26 - 0
src/handlers/settings.cpp

@@ -66,5 +66,31 @@ void registerSettingsRoutes(ApiServer& s) {
         d->settings.save(updated);
         sendJson(res, 200, {{"ok", true}});
     });
+
+    // Server-global read-only lock (not per project). GET reflects the DB's own
+    // authoritative state — the DB can also enter read-only by itself after a
+    // failed/degraded recovery, not only via an operator's PUT here, so `reason`
+    // (when non-empty) tells the caller which it was.
+    svr.Get(R"(/api/v1/admin/readonly)", [d](const httplib::Request& req, httplib::Response& res) {
+        requireAdmin(requireKey(*d, req));
+        auto st = d->db.client().getReadOnlyStatus();
+        nlohmann::json j{{"readonly", st.readOnly}, {"scope", "server"}};
+        if (!st.reason.empty()) j["reason"] = st.reason;
+        sendJson(res, 200, j);
+    });
+
+    svr.Put(R"(/api/v1/admin/readonly)", [d](const httplib::Request& req, httplib::Response& res) {
+        requireAdmin(requireKey(*d, req));
+        auto body = bodyJson(req);
+        if (!body.is_object() || !body.contains("readonly") || !body["readonly"].is_boolean())
+            throw ApiError(ErrCode::Unprocessable, "validation", "readonly (boolean) is required");
+        const bool ro = body["readonly"].get<bool>();
+        if (!d->db.client().setReadOnly(ro))
+            throw ApiError(ErrCode::Unavailable, "db_error", "failed to set read-only mode");
+        auto st = d->db.client().getReadOnlyStatus();
+        nlohmann::json j{{"readonly", st.readOnly}, {"scope", "server"}};
+        if (!st.reason.empty()) j["reason"] = st.reason;
+        sendJson(res, 200, j);
+    });
 }
 } // namespace svapi

+ 55 - 0
tests/test_api_integration.cpp

@@ -1159,3 +1159,58 @@ TEST_F(ApiFixture, RelationsEnforcedOnParentPermitsDelete) {
     ASSERT_TRUE(off); EXPECT_EQ(off->status, 200);
     EXPECT_EQ(c.Delete((base + "/cust2/documents/" + pid).c_str())->status, 200);
 }
+
+TEST_F(ApiFixture, AdminReadonlyLockBlocksWritesThenReleases) {
+    auto c = admin();
+    std::string base = "/api/v1/projects/" + project_ + "/collections";
+    ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","rolock"},{"kind","json"}}.dump(),
+                     "application/json")->status, 201);
+    std::string docs = base + "/rolock/documents";
+
+    // RAII guard: unconditionally releases the server-global lock when the test
+    // function returns (including on an early ASSERT_* failure), so a mid-test
+    // assertion can never leave the DB locked for every later test in the suite.
+    struct UnlockGuard {
+        httplib::Client& c;
+        ~UnlockGuard() {
+            c.Put("/api/v1/admin/readonly", nlohmann::json{{"readonly", false}}.dump(),
+                 "application/json");
+        }
+    } unlockGuard{c};
+
+    auto on = c.Put("/api/v1/admin/readonly", nlohmann::json{{"readonly", true}}.dump(),
+                    "application/json");
+    ASSERT_TRUE(on); EXPECT_EQ(on->status, 200);
+    // GET is now authoritative (backed by the DB's own getReadOnlyStatus()), not a
+    // locally-tracked flag -- assert it reflects what the server itself reports.
+    auto st1 = c.Get("/api/v1/admin/readonly");
+    ASSERT_TRUE(st1); EXPECT_EQ(st1->status, 200);
+    auto st1Json = nlohmann::json::parse(st1->body);
+    EXPECT_TRUE(st1Json["readonly"].get<bool>());
+    EXPECT_EQ(st1Json["scope"], "server");
+
+    auto blocked = c.Post(docs.c_str(), nlohmann::json{{"v",1}}.dump(), "application/json");
+    ASSERT_TRUE(blocked); EXPECT_NE(blocked->status, 201);
+
+    auto off = c.Put("/api/v1/admin/readonly", nlohmann::json{{"readonly", false}}.dump(),
+                     "application/json");
+    ASSERT_TRUE(off); EXPECT_EQ(off->status, 200);
+    auto st2 = c.Get("/api/v1/admin/readonly");
+    ASSERT_TRUE(st2); EXPECT_EQ(st2->status, 200);
+    EXPECT_FALSE(nlohmann::json::parse(st2->body)["readonly"].get<bool>());
+    EXPECT_EQ(c.Post(docs.c_str(), nlohmann::json{{"v",2}}.dump(), "application/json")->status, 201);
+}
+
+TEST_F(ApiFixture, ReadonlyRequiresAdmin) {
+    // The fixture's scoped-key helper is used by the existing scoped-key tests;
+    // reuse the same construction to assert a non-admin key is refused.
+    auto c = admin();
+    auto made = c.Post("/api/v1/keys", nlohmann::json{
+        {"label","ro-nonadmin"}, {"projects", nlohmann::json::array({project_})},
+        {"admin", false}}.dump(), "application/json");
+    ASSERT_EQ(made->status, 201);
+    std::string nonAdmin = nlohmann::json::parse(made->body)["key"];
+    auto r = request("PUT", "/api/v1/admin/readonly",
+                     nlohmann::json{{"readonly", true}}.dump(), "application/json", nonAdmin);
+    ASSERT_TRUE(r); EXPECT_EQ(r->status, 403);
+}