|
|
@@ -1,5 +1,7 @@
|
|
|
#include "api_fixture.hpp"
|
|
|
#include "embedding_cache.hpp"
|
|
|
+#include <thread>
|
|
|
+#include <chrono>
|
|
|
using svapi::testutil::ApiFixture;
|
|
|
|
|
|
TEST_F(ApiFixture, HealthzPublic) { auto r = noAuth().Get("/healthz"); ASSERT_TRUE(r); EXPECT_EQ(r->status, 200); }
|
|
|
@@ -816,3 +818,78 @@ TEST_F(ApiFixture, CreateScopedKeyAndReject) {
|
|
|
ASSERT_TRUE(badOp);
|
|
|
EXPECT_EQ(badOp->status, 422) << "unknown op must be rejected: " << badOp->body;
|
|
|
}
|
|
|
+
|
|
|
+// Task 14: CAPTCHA-gated insert: scoped key with require_human_token must enforce X-Captcha-Token.
|
|
|
+TEST_F(ApiFixture, CaptchaGatedInsert) {
|
|
|
+ // Spin up an in-process mock captcha server.
|
|
|
+ const std::string goodToken = "captcha-good-token-xyz";
|
|
|
+ httplib::Server captchaMock;
|
|
|
+ captchaMock.Post("/verify", [&goodToken](const httplib::Request& req, httplib::Response& res) {
|
|
|
+ bool ok = req.body.find(goodToken) != std::string::npos;
|
|
|
+ res.set_content(ok ? R"({"success":true})" : R"({"success":false})", "application/json");
|
|
|
+ });
|
|
|
+ int captchaPort = captchaMock.bind_to_any_port("127.0.0.1");
|
|
|
+ std::thread captchaThread([&captchaMock]{ captchaMock.listen_after_bind(); });
|
|
|
+ while (!captchaMock.is_running())
|
|
|
+ std::this_thread::sleep_for(std::chrono::milliseconds(5));
|
|
|
+
|
|
|
+ // Configure captcha settings.
|
|
|
+ {
|
|
|
+ svapi::Settings s = *settings_->snapshot();
|
|
|
+ s.captchaProvider = "turnstile";
|
|
|
+ s.captchaSecret = "test-secret";
|
|
|
+ s.captchaVerifyUrl = "http://127.0.0.1:" + std::to_string(captchaPort) + "/verify";
|
|
|
+ settings_->save(s);
|
|
|
+ }
|
|
|
+
|
|
|
+ // Create a json collection.
|
|
|
+ const std::string base = "/api/v1/projects/" + project_ + "/collections";
|
|
|
+ ASSERT_EQ(admin().Post(base.c_str(),
|
|
|
+ nlohmann::json{{"name","quotes"},{"kind","json"}}.dump(), "application/json")->status, 201);
|
|
|
+
|
|
|
+ // Build a scoped key with require_human_token:true on "quotes"/insert.
|
|
|
+ svapi::ApiKey sk;
|
|
|
+ sk.id = svapi::generateApiKey();
|
|
|
+ sk.key = svapi::generateApiKey();
|
|
|
+ sk.label = "captcha-test";
|
|
|
+ sk.projects = {project_};
|
|
|
+ sk.admin = false;
|
|
|
+ sk.createdAt = 0;
|
|
|
+ {
|
|
|
+ svapi::KeyScope sc;
|
|
|
+ svapi::KeyScopeRule r;
|
|
|
+ r.collection = "quotes";
|
|
|
+ r.ops = {svapi::KeyOp::Insert};
|
|
|
+ r.requireHumanToken = true;
|
|
|
+ sc.rules.push_back(std::move(r));
|
|
|
+ sk.scope = std::move(sc);
|
|
|
+ }
|
|
|
+ db_->client().upsert(keysColl_, sk.toJson(), sk.id);
|
|
|
+ keys_->bootstrap("");
|
|
|
+
|
|
|
+ const std::string path = base + "/quotes/documents";
|
|
|
+ const std::string body = nlohmann::json{{"data",{{"q","test"}}}}.dump();
|
|
|
+ const std::string ct = "application/json";
|
|
|
+
|
|
|
+ // POST without X-Captcha-Token → 403
|
|
|
+ auto noToken = request("POST", path, body, ct, sk.key);
|
|
|
+ ASSERT_TRUE(noToken);
|
|
|
+ EXPECT_EQ(noToken->status, 403) << "missing captcha token must be 403; body: " << noToken->body;
|
|
|
+
|
|
|
+ // POST with good token → 201
|
|
|
+ auto goodRes = request("POST", path, body, ct, sk.key, {{"X-Captcha-Token", goodToken}});
|
|
|
+ ASSERT_TRUE(goodRes);
|
|
|
+ EXPECT_EQ(goodRes->status, 201) << "good captcha token must be 201; body: " << goodRes->body;
|
|
|
+
|
|
|
+ // Cleanup
|
|
|
+ captchaMock.stop();
|
|
|
+ captchaThread.join();
|
|
|
+ admin().Delete((base + "/quotes").c_str());
|
|
|
+ db_->client().remove(keysColl_, sk.id);
|
|
|
+ // Restore captcha settings to disabled.
|
|
|
+ svapi::Settings s = *settings_->snapshot();
|
|
|
+ s.captchaProvider = "";
|
|
|
+ s.captchaSecret = "";
|
|
|
+ s.captchaVerifyUrl = "";
|
|
|
+ settings_->save(s);
|
|
|
+}
|