|
@@ -164,3 +164,96 @@ TEST_F(ApiFixture, OpenApiDescribesProjectRoutes) {
|
|
|
EXPECT_TRUE(j["paths"].contains("/api/v1/projects/{project}/collections/{name}/search"));
|
|
EXPECT_TRUE(j["paths"].contains("/api/v1/projects/{project}/collections/{name}/search"));
|
|
|
EXPECT_TRUE(j["paths"].contains("/api/v1/keys"));
|
|
EXPECT_TRUE(j["paths"].contains("/api/v1/keys"));
|
|
|
}
|
|
}
|
|
|
|
|
+
|
|
|
|
|
+// Fix 1 regression: CORS multi-origin
|
|
|
|
|
+TEST_F(ApiFixture, CorsEchosAllowedOrigin) {
|
|
|
|
|
+ httplib::Client c("127.0.0.1", port_);
|
|
|
|
|
+ c.set_default_headers({{"Authorization", "Bearer " + adminKey_},
|
|
|
|
|
+ {"Origin", "https://example.com"}});
|
|
|
|
|
+ // Seed corsOrigins with a specific origin via settings
|
|
|
|
|
+ svapi::Settings s = *settings_->snapshot();
|
|
|
|
|
+ s.corsOrigins = {"https://example.com", "https://other.com"};
|
|
|
|
|
+ settings_->save(s);
|
|
|
|
|
+ auto r = c.Get("/api/v1/projects");
|
|
|
|
|
+ ASSERT_TRUE(r);
|
|
|
|
|
+ EXPECT_EQ(r->get_header_value("Access-Control-Allow-Origin"), "https://example.com");
|
|
|
|
|
+ // Vary may contain multiple values; check that "Origin" appears somewhere in them
|
|
|
|
|
+ bool varyHasOrigin = false;
|
|
|
|
|
+ for (const auto& [name, val] : r->headers)
|
|
|
|
|
+ if (name == "Vary" && val.find("Origin") != std::string::npos) { varyHasOrigin = true; break; }
|
|
|
|
|
+ EXPECT_TRUE(varyHasOrigin);
|
|
|
|
|
+ // Restore
|
|
|
|
|
+ s.corsOrigins = {"*"};
|
|
|
|
|
+ settings_->save(s);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+TEST_F(ApiFixture, CorsRejectsUnknownOrigin) {
|
|
|
|
|
+ httplib::Client c("127.0.0.1", port_);
|
|
|
|
|
+ c.set_default_headers({{"Authorization", "Bearer " + adminKey_},
|
|
|
|
|
+ {"Origin", "https://evil.com"}});
|
|
|
|
|
+ svapi::Settings s = *settings_->snapshot();
|
|
|
|
|
+ s.corsOrigins = {"https://example.com"};
|
|
|
|
|
+ settings_->save(s);
|
|
|
|
|
+ auto r = c.Get("/api/v1/projects");
|
|
|
|
|
+ ASSERT_TRUE(r);
|
|
|
|
|
+ EXPECT_TRUE(r->get_header_value("Access-Control-Allow-Origin").empty());
|
|
|
|
|
+ // Restore
|
|
|
|
|
+ s.corsOrigins = {"*"};
|
|
|
|
|
+ settings_->save(s);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Fix 2 regression: bad limit/offset → 422
|
|
|
|
|
+TEST_F(ApiFixture, BadLimitReturns422) {
|
|
|
|
|
+ auto c = admin();
|
|
|
|
|
+ std::string base = "/api/v1/projects/" + project_ + "/collections";
|
|
|
|
|
+ ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","limitcoll"},{"kind","json"}}.dump(),"application/json")->status, 201);
|
|
|
|
|
+ auto r = c.Get((base + "/limitcoll/documents?limit=abc").c_str());
|
|
|
|
|
+ ASSERT_TRUE(r);
|
|
|
|
|
+ EXPECT_EQ(r->status, 422);
|
|
|
|
|
+ c.Delete((base + "/limitcoll").c_str());
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Fix 3 regression: DELETE project cleans up stale registry → recycle works
|
|
|
|
|
+TEST_F(ApiFixture, ProjectRecycleAfterDelete) {
|
|
|
|
|
+ auto c = admin();
|
|
|
|
|
+ std::string p2 = "svapitest_recycle";
|
|
|
|
|
+ db_->dropProject(p2);
|
|
|
|
|
+ db_->client().dropCollection(svapi::qualify(p2, "vectorapi_collections"));
|
|
|
|
|
+ // Create project and collection
|
|
|
|
|
+ ASSERT_EQ(c.Post("/api/v1/projects", nlohmann::json{{"name", p2}}.dump(), "application/json")->status, 201);
|
|
|
|
|
+ std::string base = "/api/v1/projects/" + p2 + "/collections";
|
|
|
|
|
+ ASSERT_EQ(c.Post(base.c_str(), nlohmann::json{{"name","users"},{"kind","json"}}.dump(), "application/json")->status, 201);
|
|
|
|
|
+ // Delete the project (should purge collections + registry)
|
|
|
|
|
+ ASSERT_EQ(c.Delete(("/api/v1/projects/" + p2).c_str())->status, 200);
|
|
|
|
|
+ // Re-create project and collection — must succeed (201, not 422)
|
|
|
|
|
+ ASSERT_EQ(c.Post("/api/v1/projects", nlohmann::json{{"name", p2}}.dump(), "application/json")->status, 201);
|
|
|
|
|
+ auto r = c.Post(base.c_str(), nlohmann::json{{"name","users"},{"kind","json"}}.dump(), "application/json");
|
|
|
|
|
+ ASSERT_TRUE(r);
|
|
|
|
|
+ EXPECT_EQ(r->status, 201);
|
|
|
|
|
+ // Clean up
|
|
|
|
|
+ c.Delete(("/api/v1/projects/" + p2).c_str());
|
|
|
|
|
+ db_->dropProject(p2);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Fix 5 regression: cannot delete the last admin key
|
|
|
|
|
+TEST_F(ApiFixture, CannotDeleteLastAdminKey) {
|
|
|
|
|
+ auto r = admin().Delete(("/api/v1/keys/" + adminKey_).c_str());
|
|
|
|
|
+ ASSERT_TRUE(r);
|
|
|
|
|
+ EXPECT_EQ(r->status, 422);
|
|
|
|
|
+ // Key still works
|
|
|
|
|
+ auto check = admin().Get("/api/v1/projects");
|
|
|
|
|
+ ASSERT_TRUE(check);
|
|
|
|
|
+ EXPECT_EQ(check->status, 200);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Fix 5 regression: cannot de-admin the last admin key
|
|
|
|
|
+TEST_F(ApiFixture, CannotDeAdminLastAdminKey) {
|
|
|
|
|
+ auto r = admin().Patch(("/api/v1/keys/" + adminKey_).c_str(),
|
|
|
|
|
+ nlohmann::json{{"admin", false}}.dump(), "application/json");
|
|
|
|
|
+ ASSERT_TRUE(r);
|
|
|
|
|
+ EXPECT_EQ(r->status, 422);
|
|
|
|
|
+ // Key still works as admin
|
|
|
|
|
+ auto check = admin().Get("/api/v1/keys");
|
|
|
|
|
+ ASSERT_TRUE(check);
|
|
|
|
|
+ EXPECT_EQ(check->status, 200);
|
|
|
|
|
+}
|