|
|
@@ -166,6 +166,17 @@ TEST_F(ApiFixture, ProjectStatsAndGlobalStats) {
|
|
|
EXPECT_TRUE(gJson.contains("memory_pressure_level"));
|
|
|
}
|
|
|
|
|
|
+TEST_F(ApiFixture, StatsReportsLoadedClientLibraryVersion) {
|
|
|
+ auto r = admin().Get("/api/v1/stats");
|
|
|
+ ASSERT_TRUE(r); ASSERT_EQ(r->status, 200);
|
|
|
+ auto body = nlohmann::json::parse(r->body);
|
|
|
+ ASSERT_TRUE(body.contains("db_client_version"));
|
|
|
+ ASSERT_TRUE(body.contains("db_client_commit"));
|
|
|
+ // Reports the LOADED library, so it must be non-empty and 2.x.
|
|
|
+ EXPECT_FALSE(body["db_client_version"].get<std::string>().empty());
|
|
|
+ EXPECT_EQ(body["db_client_version"].get<std::string>().rfind("2.", 0), 0u);
|
|
|
+}
|
|
|
+
|
|
|
TEST_F(ApiFixture, OpenApiDescribesProjectRoutes) {
|
|
|
auto r = noAuth().Get("/openapi.json");
|
|
|
ASSERT_EQ(r->status, 200);
|
|
|
@@ -893,3 +904,355 @@ TEST_F(ApiFixture, CaptchaGatedInsert) {
|
|
|
s.captchaVerifyUrl = "";
|
|
|
settings_->save(s);
|
|
|
}
|
|
|
+
|
|
|
+TEST_F(ApiFixture, IndexCreateListDrop) {
|
|
|
+ auto c = admin();
|
|
|
+ std::string base = "/api/v1/projects/" + project_ + "/collections";
|
|
|
+ ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","idxc"},{"kind","json"}}.dump(),
|
|
|
+ "application/json")->status, 201);
|
|
|
+ std::string idx = base + "/idxc/indexes";
|
|
|
+
|
|
|
+ auto made = c.Post(idx.c_str(), nlohmann::json{{"field","status"}}.dump(), "application/json");
|
|
|
+ ASSERT_TRUE(made); EXPECT_EQ(made->status, 201);
|
|
|
+ auto madeJson = nlohmann::json::parse(made->body);
|
|
|
+ EXPECT_EQ(madeJson["field"], "status");
|
|
|
+ EXPECT_FALSE(madeJson["unique"].get<bool>());
|
|
|
+
|
|
|
+ auto list = c.Get(idx.c_str());
|
|
|
+ ASSERT_EQ(list->status, 200);
|
|
|
+ auto listJson = nlohmann::json::parse(list->body);
|
|
|
+ bool found = false;
|
|
|
+ for (auto& e : listJson["indexes"]) if (e["field"] == "status") found = true;
|
|
|
+ EXPECT_TRUE(found);
|
|
|
+
|
|
|
+ EXPECT_EQ(c.Delete((idx + "/status").c_str())->status, 200);
|
|
|
+ auto after = c.Get(idx.c_str());
|
|
|
+ auto afterJson = nlohmann::json::parse(after->body);
|
|
|
+ for (auto& e : afterJson["indexes"]) EXPECT_NE(e["field"], "status");
|
|
|
+}
|
|
|
+
|
|
|
+TEST_F(ApiFixture, IndexCreateRequiresField) {
|
|
|
+ auto c = admin();
|
|
|
+ std::string base = "/api/v1/projects/" + project_ + "/collections";
|
|
|
+ ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","idxv"},{"kind","json"}}.dump(),
|
|
|
+ "application/json")->status, 201);
|
|
|
+ auto bad = c.Post((base + "/idxv/indexes").c_str(), nlohmann::json{}.dump(), "application/json");
|
|
|
+ ASSERT_TRUE(bad); EXPECT_EQ(bad->status, 422);
|
|
|
+}
|
|
|
+
|
|
|
+TEST_F(ApiFixture, IndexValuesReturnsFacetCounts) {
|
|
|
+ auto c = admin();
|
|
|
+ std::string base = "/api/v1/projects/" + project_ + "/collections";
|
|
|
+ ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","facets"},{"kind","json"}}.dump(),
|
|
|
+ "application/json")->status, 201);
|
|
|
+ std::string docs = base + "/facets/documents";
|
|
|
+ for (const char* st : {"open", "open", "closed"})
|
|
|
+ ASSERT_EQ(c.Post(docs.c_str(), nlohmann::json{{"status", st}}.dump(), "application/json")->status, 201);
|
|
|
+ ASSERT_EQ(c.Post((base + "/facets/indexes").c_str(),
|
|
|
+ nlohmann::json{{"field","status"}}.dump(), "application/json")->status, 201);
|
|
|
+
|
|
|
+ auto r = c.Get((base + "/facets/indexes/status/values").c_str());
|
|
|
+ ASSERT_TRUE(r); EXPECT_EQ(r->status, 200);
|
|
|
+ auto body = nlohmann::json::parse(r->body);
|
|
|
+ uint64_t open = 0, closed = 0;
|
|
|
+ for (auto& e : body["values"]) {
|
|
|
+ if (e["value"] == "open") open = e["count"].get<uint64_t>();
|
|
|
+ if (e["value"] == "closed") closed = e["count"].get<uint64_t>();
|
|
|
+ }
|
|
|
+ EXPECT_EQ(open, 2u);
|
|
|
+ EXPECT_EQ(closed, 1u);
|
|
|
+}
|
|
|
+
|
|
|
+TEST_F(ApiFixture, IndexValuesEmptyForUnindexedField) {
|
|
|
+ auto c = admin();
|
|
|
+ std::string base = "/api/v1/projects/" + project_ + "/collections";
|
|
|
+ ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","nofacet"},{"kind","json"}}.dump(),
|
|
|
+ "application/json")->status, 201);
|
|
|
+ auto r = c.Get((base + "/nofacet/indexes/whatever/values").c_str());
|
|
|
+ ASSERT_TRUE(r); EXPECT_EQ(r->status, 200);
|
|
|
+ EXPECT_TRUE(nlohmann::json::parse(r->body)["values"].empty());
|
|
|
+}
|
|
|
+
|
|
|
+TEST_F(ApiFixture, IndexValuesRejectsOutOfRangeLimit) {
|
|
|
+ auto c = admin();
|
|
|
+ std::string base = "/api/v1/projects/" + project_ + "/collections";
|
|
|
+ ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","limitrange"},{"kind","json"}}.dump(),
|
|
|
+ "application/json")->status, 201);
|
|
|
+ ASSERT_EQ(c.Post((base + "/limitrange/indexes").c_str(),
|
|
|
+ nlohmann::json{{"field","status"}}.dump(), "application/json")->status, 201);
|
|
|
+
|
|
|
+ // 2^32 + 100: fits cleanly in the unsigned long strtoul returns, but is far
|
|
|
+ // out of the uint32_t range once narrowed. Must be rejected, not silently
|
|
|
+ // truncated down to a small in-range value.
|
|
|
+ auto overflow = c.Get((base + "/limitrange/indexes/status/values?limit=4294967396").c_str());
|
|
|
+ ASSERT_TRUE(overflow); EXPECT_EQ(overflow->status, 422);
|
|
|
+
|
|
|
+ // Existing behavior that must be preserved: non-numeric and empty-string
|
|
|
+ // limits still 422 (strtoul yields 0, caught by the ==0 check), and the
|
|
|
+ // default of 100 applies when the parameter is absent.
|
|
|
+ auto nonNumeric = c.Get((base + "/limitrange/indexes/status/values?limit=abc").c_str());
|
|
|
+ ASSERT_TRUE(nonNumeric); EXPECT_EQ(nonNumeric->status, 422);
|
|
|
+
|
|
|
+ auto empty = c.Get((base + "/limitrange/indexes/status/values?limit=").c_str());
|
|
|
+ ASSERT_TRUE(empty); EXPECT_EQ(empty->status, 422);
|
|
|
+
|
|
|
+ auto absent = c.Get((base + "/limitrange/indexes/status/values").c_str());
|
|
|
+ ASSERT_TRUE(absent); EXPECT_EQ(absent->status, 200);
|
|
|
+}
|
|
|
+
|
|
|
+TEST_F(ApiFixture, UniqueIndexRejectsExistingDuplicates) {
|
|
|
+ auto c = admin();
|
|
|
+ std::string base = "/api/v1/projects/" + project_ + "/collections";
|
|
|
+ ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","uq"},{"kind","json"}}.dump(),
|
|
|
+ "application/json")->status, 201);
|
|
|
+ std::string docs = base + "/uq/documents";
|
|
|
+ ASSERT_EQ(c.Post(docs.c_str(), nlohmann::json{{"sku","A1"}}.dump(), "application/json")->status, 201);
|
|
|
+ ASSERT_EQ(c.Post(docs.c_str(), nlohmann::json{{"sku","A1"}}.dump(), "application/json")->status, 201);
|
|
|
+
|
|
|
+ auto conflict = c.Post((base + "/uq/indexes").c_str(),
|
|
|
+ nlohmann::json{{"field","sku"},{"unique",true}}.dump(), "application/json");
|
|
|
+ ASSERT_TRUE(conflict); EXPECT_EQ(conflict->status, 409);
|
|
|
+ auto body = nlohmann::json::parse(conflict->body);
|
|
|
+ EXPECT_EQ(body["error"]["code"], "duplicate_values");
|
|
|
+ ASSERT_TRUE(body["error"]["details"].contains("duplicate_examples"));
|
|
|
+ EXPECT_GE(body["error"]["details"]["duplicate_examples"].size(), 1u);
|
|
|
+}
|
|
|
+
|
|
|
+TEST_F(ApiFixture, UniqueIndexOnCleanFieldSucceeds) {
|
|
|
+ auto c = admin();
|
|
|
+ std::string base = "/api/v1/projects/" + project_ + "/collections";
|
|
|
+ ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","uq2"},{"kind","json"}}.dump(),
|
|
|
+ "application/json")->status, 201);
|
|
|
+ std::string docs = base + "/uq2/documents";
|
|
|
+ ASSERT_EQ(c.Post(docs.c_str(), nlohmann::json{{"sku","B1"}}.dump(), "application/json")->status, 201);
|
|
|
+ ASSERT_EQ(c.Post(docs.c_str(), nlohmann::json{{"sku","B2"}}.dump(), "application/json")->status, 201);
|
|
|
+
|
|
|
+ auto made = c.Post((base + "/uq2/indexes").c_str(),
|
|
|
+ nlohmann::json{{"field","sku"},{"unique",true}}.dump(), "application/json");
|
|
|
+ ASSERT_TRUE(made); EXPECT_EQ(made->status, 201);
|
|
|
+ EXPECT_TRUE(nlohmann::json::parse(made->body)["unique"].get<bool>());
|
|
|
+}
|
|
|
+
|
|
|
+TEST_F(ApiFixture, DocumentTtlAcceptedOnInsertAndPatch) {
|
|
|
+ auto c = admin();
|
|
|
+ std::string base = "/api/v1/projects/" + project_ + "/collections";
|
|
|
+ ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","ttlc"},{"kind","json"}}.dump(),
|
|
|
+ "application/json")->status, 201);
|
|
|
+ std::string docs = base + "/ttlc/documents";
|
|
|
+
|
|
|
+ auto made = c.Post((docs + "?ttl_seconds=3600").c_str(),
|
|
|
+ nlohmann::json{{"v",1}}.dump(), "application/json");
|
|
|
+ ASSERT_TRUE(made); EXPECT_EQ(made->status, 201);
|
|
|
+ std::string id = nlohmann::json::parse(made->body)["id"];
|
|
|
+
|
|
|
+ // Document is readable while the TTL is in the future.
|
|
|
+ EXPECT_EQ(c.Get((docs + "/" + id).c_str())->status, 200);
|
|
|
+
|
|
|
+ // ttl_seconds=0 on PATCH clears the expiry (does not delete the document).
|
|
|
+ auto cleared = c.Patch((docs + "/" + id + "?ttl_seconds=0").c_str(),
|
|
|
+ nlohmann::json{{"v",2}}.dump(), "application/json");
|
|
|
+ ASSERT_TRUE(cleared); EXPECT_EQ(cleared->status, 200);
|
|
|
+ auto got = c.Get((docs + "/" + id).c_str());
|
|
|
+ ASSERT_EQ(got->status, 200);
|
|
|
+ EXPECT_EQ(nlohmann::json::parse(got->body)["v"], 2);
|
|
|
+}
|
|
|
+
|
|
|
+TEST_F(ApiFixture, DocumentTtlRejectsNonNumeric) {
|
|
|
+ auto c = admin();
|
|
|
+ std::string base = "/api/v1/projects/" + project_ + "/collections";
|
|
|
+ ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","ttlbad"},{"kind","json"}}.dump(),
|
|
|
+ "application/json")->status, 201);
|
|
|
+ auto bad = c.Post((base + "/ttlbad/documents?ttl_seconds=soon").c_str(),
|
|
|
+ nlohmann::json{{"v",1}}.dump(), "application/json");
|
|
|
+ ASSERT_TRUE(bad); EXPECT_EQ(bad->status, 422);
|
|
|
+}
|
|
|
+
|
|
|
+TEST_F(ApiFixture, RelationCrud) {
|
|
|
+ auto c = admin();
|
|
|
+ std::string base = "/api/v1/projects/" + project_ + "/collections";
|
|
|
+ ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","orders"},{"kind","json"}}.dump(),
|
|
|
+ "application/json")->status, 201);
|
|
|
+ ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","customers"},{"kind","json"}}.dump(),
|
|
|
+ "application/json")->status, 201);
|
|
|
+
|
|
|
+ std::string rel = "/api/v1/projects/" + project_ + "/relations";
|
|
|
+ auto made = c.Post(rel.c_str(), nlohmann::json{
|
|
|
+ {"name","order_customer"}, {"child","orders"}, {"child_field","customer_id"},
|
|
|
+ {"parent","customers"}, {"on_delete","restrict"}}.dump(), "application/json");
|
|
|
+ ASSERT_TRUE(made); EXPECT_EQ(made->status, 201);
|
|
|
+
|
|
|
+ auto list = c.Get(rel.c_str());
|
|
|
+ ASSERT_EQ(list->status, 200);
|
|
|
+ auto listJson = nlohmann::json::parse(list->body);
|
|
|
+ bool found = false;
|
|
|
+ for (auto& e : listJson["relations"])
|
|
|
+ if (e["name"] == "order_customer") { found = true; EXPECT_EQ(e["child"], "orders"); }
|
|
|
+ EXPECT_TRUE(found);
|
|
|
+
|
|
|
+ auto one = c.Get((rel + "/order_customer").c_str());
|
|
|
+ ASSERT_EQ(one->status, 200);
|
|
|
+ EXPECT_EQ(nlohmann::json::parse(one->body)["parent"], "customers");
|
|
|
+
|
|
|
+ EXPECT_EQ(c.Delete((rel + "/order_customer").c_str())->status, 200);
|
|
|
+ EXPECT_EQ(c.Get((rel + "/order_customer").c_str())->status, 404);
|
|
|
+}
|
|
|
+
|
|
|
+TEST_F(ApiFixture, RelationRejectsBadOnDelete) {
|
|
|
+ auto c = admin();
|
|
|
+ std::string rel = "/api/v1/projects/" + project_ + "/relations";
|
|
|
+ auto bad = c.Post(rel.c_str(), nlohmann::json{
|
|
|
+ {"name","r"}, {"child","a"}, {"child_field","b"},
|
|
|
+ {"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 relUrl = "/api/v1/projects/" + project_ + "/relations/inv_cust";
|
|
|
+
|
|
|
+ // RAII guard: this test creates a restrict relation that must not survive the
|
|
|
+ // test, since tmpName()'s project name is deterministic and dropProject does
|
|
|
+ // not purge collection data (see db_test_util.hpp). Runs on early ASSERT_*
|
|
|
+ // returns too. A missing relation on cleanup is fine (idempotent delete).
|
|
|
+ struct RelationGuard {
|
|
|
+ httplib::Client& c;
|
|
|
+ std::string url;
|
|
|
+ ~RelationGuard() { c.Delete(url.c_str()); }
|
|
|
+ } relGuard{c, relUrl};
|
|
|
+
|
|
|
+ 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";
|
|
|
+ std::string relUrl = "/api/v1/projects/" + project_ + "/relations/inv2_cust2";
|
|
|
+ std::string enforcedUrl = base + "/cust2/relations-enforced";
|
|
|
+
|
|
|
+ // RAII guard: this test creates a restrict relation and flips relations_enforced
|
|
|
+ // to false on cust2 - both must be undone unconditionally, since tmpName()'s
|
|
|
+ // project name is deterministic and dropProject does not purge collection data
|
|
|
+ // (see db_test_util.hpp). Runs on early ASSERT_* returns too. A missing relation
|
|
|
+ // on cleanup is fine (idempotent delete); restoring enforced=true is idempotent too.
|
|
|
+ struct RelationEnforcedGuard {
|
|
|
+ httplib::Client& c;
|
|
|
+ std::string relUrl;
|
|
|
+ std::string enforcedUrl;
|
|
|
+ ~RelationEnforcedGuard() {
|
|
|
+ c.Delete(relUrl.c_str());
|
|
|
+ c.Put(enforcedUrl.c_str(), nlohmann::json{{"enforced", true}}.dump(),
|
|
|
+ "application/json");
|
|
|
+ }
|
|
|
+ } relGuard{c, relUrl, enforcedUrl};
|
|
|
+
|
|
|
+ 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);
|
|
|
+}
|
|
|
+
|
|
|
+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);
|
|
|
+}
|