|
@@ -564,6 +564,213 @@ TEST_F(ApiFixture, ScopedKeyCannotManageCollections) {
|
|
|
db_->client().remove(keysColl_, scopedKey.id);
|
|
db_->client().remove(keysColl_, scopedKey.id);
|
|
|
}
|
|
}
|
|
|
|
|
|
|
|
|
|
+// U5a: scoped key op+collection matrix — only explicitly granted (collection, op) pairs succeed.
|
|
|
|
|
+TEST_F(ApiFixture, ScopedKeyOpAndCollectionMatrix) {
|
|
|
|
|
+ // Create collections: designs (json), quotes (json), other (json).
|
|
|
|
|
+ const std::string base = "/api/v1/projects/" + project_ + "/collections";
|
|
|
|
|
+ ASSERT_EQ(admin().Post(base.c_str(),
|
|
|
|
|
+ nlohmann::json{{"name","designs"},{"kind","json"}}.dump(), "application/json")->status, 201);
|
|
|
|
|
+ ASSERT_EQ(admin().Post(base.c_str(),
|
|
|
|
|
+ nlohmann::json{{"name","quotes"},{"kind","json"}}.dump(), "application/json")->status, 201);
|
|
|
|
|
+ ASSERT_EQ(admin().Post(base.c_str(),
|
|
|
|
|
+ nlohmann::json{{"name","other"},{"kind","json"}}.dump(), "application/json")->status, 201);
|
|
|
|
|
+
|
|
|
|
|
+ // Build a scoped key: designs=[read,insert], quotes=[insert], origins=[] (any).
|
|
|
|
|
+ svapi::ApiKey sk;
|
|
|
|
|
+ sk.id = svapi::generateApiKey();
|
|
|
|
|
+ sk.key = svapi::generateApiKey();
|
|
|
|
|
+ sk.label = "matrix-test";
|
|
|
|
|
+ sk.projects = {project_};
|
|
|
|
|
+ sk.admin = false;
|
|
|
|
|
+ sk.createdAt = 0;
|
|
|
|
|
+ {
|
|
|
|
|
+ svapi::KeyScope sc;
|
|
|
|
|
+ svapi::KeyScopeRule r1;
|
|
|
|
|
+ r1.collection = "designs";
|
|
|
|
|
+ r1.ops = {svapi::KeyOp::Read, svapi::KeyOp::Insert};
|
|
|
|
|
+ svapi::KeyScopeRule r2;
|
|
|
|
|
+ r2.collection = "quotes";
|
|
|
|
|
+ r2.ops = {svapi::KeyOp::Insert};
|
|
|
|
|
+ sc.rules.push_back(std::move(r1));
|
|
|
|
|
+ sc.rules.push_back(std::move(r2));
|
|
|
|
|
+ sk.scope = std::move(sc);
|
|
|
|
|
+ }
|
|
|
|
|
+ db_->client().upsert(keysColl_, sk.toJson(), sk.id);
|
|
|
|
|
+ keys_->bootstrap("");
|
|
|
|
|
+
|
|
|
|
|
+ const std::string jDoc = nlohmann::json{{"data",{{"x",1}}}}.dump();
|
|
|
|
|
+ const std::string ct = "application/json";
|
|
|
|
|
+
|
|
|
|
|
+ // --- designs: insert allowed → 201 ---
|
|
|
|
|
+ auto insDesigns = request("POST", base + "/designs/documents", jDoc, ct, sk.key);
|
|
|
|
|
+ ASSERT_TRUE(insDesigns);
|
|
|
|
|
+ EXPECT_EQ(insDesigns->status, 201) << "designs insert expected 201; body: " << insDesigns->body;
|
|
|
|
|
+ std::string designsId = nlohmann::json::parse(insDesigns->body)["id"];
|
|
|
|
|
+
|
|
|
|
|
+ // --- designs: read allowed → 200 ---
|
|
|
|
|
+ auto rdDesigns = request("GET", base + "/designs/documents/" + designsId, "", ct, sk.key);
|
|
|
|
|
+ ASSERT_TRUE(rdDesigns);
|
|
|
|
|
+ EXPECT_EQ(rdDesigns->status, 200) << "designs read expected 200; body: " << rdDesigns->body;
|
|
|
|
|
+
|
|
|
|
|
+ // --- designs: list NOT granted → 403 ---
|
|
|
|
|
+ auto listDesigns = request("GET", base + "/designs/documents", "", ct, sk.key);
|
|
|
|
|
+ ASSERT_TRUE(listDesigns);
|
|
|
|
|
+ EXPECT_EQ(listDesigns->status, 403) << "designs list expected 403; body: " << listDesigns->body;
|
|
|
|
|
+
|
|
|
|
|
+ // --- designs: delete NOT granted → 403 ---
|
|
|
|
|
+ auto delDesigns = request("DELETE", base + "/designs/documents/" + designsId, "", ct, sk.key);
|
|
|
|
|
+ ASSERT_TRUE(delDesigns);
|
|
|
|
|
+ EXPECT_EQ(delDesigns->status, 403) << "designs delete expected 403; body: " << delDesigns->body;
|
|
|
|
|
+
|
|
|
|
|
+ // --- quotes: insert allowed → 201 ---
|
|
|
|
|
+ auto insQuotes = request("POST", base + "/quotes/documents", jDoc, ct, sk.key);
|
|
|
|
|
+ ASSERT_TRUE(insQuotes);
|
|
|
|
|
+ EXPECT_EQ(insQuotes->status, 201) << "quotes insert expected 201; body: " << insQuotes->body;
|
|
|
|
|
+ std::string quotesId = nlohmann::json::parse(insQuotes->body)["id"];
|
|
|
|
|
+
|
|
|
|
|
+ // --- quotes: read NOT granted → 403 ---
|
|
|
|
|
+ auto rdQuotes = request("GET", base + "/quotes/documents/" + quotesId, "", ct, sk.key);
|
|
|
|
|
+ ASSERT_TRUE(rdQuotes);
|
|
|
|
|
+ EXPECT_EQ(rdQuotes->status, 403) << "quotes read expected 403; body: " << rdQuotes->body;
|
|
|
|
|
+
|
|
|
|
|
+ // --- quotes: list NOT granted → 403 ---
|
|
|
|
|
+ auto listQuotes = request("GET", base + "/quotes/documents", "", ct, sk.key);
|
|
|
|
|
+ ASSERT_TRUE(listQuotes);
|
|
|
|
|
+ EXPECT_EQ(listQuotes->status, 403) << "quotes list expected 403; body: " << listQuotes->body;
|
|
|
|
|
+
|
|
|
|
|
+ // --- other: no rule at all → 403 for insert ---
|
|
|
|
|
+ auto insOther = request("POST", base + "/other/documents", jDoc, ct, sk.key);
|
|
|
|
|
+ ASSERT_TRUE(insOther);
|
|
|
|
|
+ EXPECT_EQ(insOther->status, 403) << "other insert expected 403; body: " << insOther->body;
|
|
|
|
|
+
|
|
|
|
|
+ // Cleanup
|
|
|
|
|
+ admin().Delete((base + "/designs").c_str());
|
|
|
|
|
+ admin().Delete((base + "/quotes").c_str());
|
|
|
|
|
+ admin().Delete((base + "/other").c_str());
|
|
|
|
|
+ db_->client().remove(keysColl_, sk.id);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// U5b: scoped key with an origins list enforces the Origin header — fails closed (no header → 403).
|
|
|
|
|
+TEST_F(ApiFixture, ScopedKeyOriginEnforced) {
|
|
|
|
|
+ const std::string base = "/api/v1/projects/" + project_ + "/collections";
|
|
|
|
|
+ ASSERT_EQ(admin().Post(base.c_str(),
|
|
|
|
|
+ nlohmann::json{{"name","origin_coll"},{"kind","json"}}.dump(), "application/json")->status, 201);
|
|
|
|
|
+
|
|
|
|
|
+ svapi::ApiKey sk;
|
|
|
|
|
+ sk.id = svapi::generateApiKey();
|
|
|
|
|
+ sk.key = svapi::generateApiKey();
|
|
|
|
|
+ sk.label = "origin-test";
|
|
|
|
|
+ sk.projects = {project_};
|
|
|
|
|
+ sk.admin = false;
|
|
|
|
|
+ sk.createdAt = 0;
|
|
|
|
|
+ {
|
|
|
|
|
+ svapi::KeyScope sc;
|
|
|
|
|
+ svapi::KeyScopeRule r;
|
|
|
|
|
+ r.collection = "origin_coll";
|
|
|
|
|
+ r.ops = {svapi::KeyOp::Insert};
|
|
|
|
|
+ sc.rules.push_back(std::move(r));
|
|
|
|
|
+ sc.origins = {"https://app.test"};
|
|
|
|
|
+ sk.scope = std::move(sc);
|
|
|
|
|
+ }
|
|
|
|
|
+ db_->client().upsert(keysColl_, sk.toJson(), sk.id);
|
|
|
|
|
+ keys_->bootstrap("");
|
|
|
|
|
+
|
|
|
|
|
+ const std::string jDoc = nlohmann::json{{"data",{{"y",2}}}}.dump();
|
|
|
|
|
+ const std::string ct = "application/json";
|
|
|
|
|
+ const std::string path = base + "/origin_coll/documents";
|
|
|
|
|
+
|
|
|
|
|
+ // Correct origin → 201
|
|
|
|
|
+ auto good = request("POST", path, jDoc, ct, sk.key, {{"Origin", "https://app.test"}});
|
|
|
|
|
+ ASSERT_TRUE(good);
|
|
|
|
|
+ EXPECT_EQ(good->status, 201) << "correct origin expected 201; body: " << good->body;
|
|
|
|
|
+
|
|
|
|
|
+ // Wrong origin → 403
|
|
|
|
|
+ auto bad = request("POST", path, jDoc, ct, sk.key, {{"Origin", "https://evil.test"}});
|
|
|
|
|
+ ASSERT_TRUE(bad);
|
|
|
|
|
+ EXPECT_EQ(bad->status, 403) << "wrong origin expected 403; body: " << bad->body;
|
|
|
|
|
+
|
|
|
|
|
+ // No Origin header → 403 (fails closed)
|
|
|
|
|
+ auto noOrigin = request("POST", path, jDoc, ct, sk.key);
|
|
|
|
|
+ ASSERT_TRUE(noOrigin);
|
|
|
|
|
+ EXPECT_EQ(noOrigin->status, 403) << "missing origin expected 403; body: " << noOrigin->body;
|
|
|
|
|
+
|
|
|
|
|
+ // Cleanup
|
|
|
|
|
+ admin().Delete((base + "/origin_coll").c_str());
|
|
|
|
|
+ db_->client().remove(keysColl_, sk.id);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// U5c: scoped key with rate_limit_per_min:2 → 3rd request in the same minute returns 429.
|
|
|
|
|
+// Use a freshly created key so prior tests do not consume this key's bucket in the singleton.
|
|
|
|
|
+TEST_F(ApiFixture, ScopedKeyRateLimited) {
|
|
|
|
|
+ const std::string base = "/api/v1/projects/" + project_ + "/collections";
|
|
|
|
|
+ ASSERT_EQ(admin().Post(base.c_str(),
|
|
|
|
|
+ nlohmann::json{{"name","rate_coll"},{"kind","json"}}.dump(), "application/json")->status, 201);
|
|
|
|
|
+
|
|
|
|
|
+ // Create the key via the API so it gets a fresh id (never seen by the rate limiter singleton).
|
|
|
|
|
+ const nlohmann::json scopeBody = {
|
|
|
|
|
+ {"rules", {{{"collection","rate_coll"},{"ops",{"insert"}}}}},
|
|
|
|
|
+ {"rate_limit_per_min", 2}
|
|
|
|
|
+ };
|
|
|
|
|
+ auto mkRes = admin().Post("/api/v1/keys",
|
|
|
|
|
+ nlohmann::json{{"label","rate-test"},{"projects",{project_}},{"admin",false},
|
|
|
|
|
+ {"scope",scopeBody}}.dump(), "application/json");
|
|
|
|
|
+ ASSERT_TRUE(mkRes); ASSERT_EQ(mkRes->status, 201) << mkRes->body;
|
|
|
|
|
+ auto mkJson = nlohmann::json::parse(mkRes->body);
|
|
|
|
|
+ std::string rkey = mkJson["key"];
|
|
|
|
|
+ std::string rid = mkJson["id"];
|
|
|
|
|
+
|
|
|
|
|
+ const std::string jDoc = nlohmann::json{{"data",{{"z",3}}}}.dump();
|
|
|
|
|
+ const std::string ct = "application/json";
|
|
|
|
|
+ const std::string path = base + "/rate_coll/documents";
|
|
|
|
|
+
|
|
|
|
|
+ // 1st request → 201
|
|
|
|
|
+ auto r1 = request("POST", path, jDoc, ct, rkey);
|
|
|
|
|
+ ASSERT_TRUE(r1);
|
|
|
|
|
+ EXPECT_EQ(r1->status, 201) << "1st request expected 201; body: " << r1->body;
|
|
|
|
|
+
|
|
|
|
|
+ // 2nd request → 201
|
|
|
|
|
+ auto r2 = request("POST", path, jDoc, ct, rkey);
|
|
|
|
|
+ ASSERT_TRUE(r2);
|
|
|
|
|
+ EXPECT_EQ(r2->status, 201) << "2nd request expected 201; body: " << r2->body;
|
|
|
|
|
+
|
|
|
|
|
+ // 3rd request within same minute → 429
|
|
|
|
|
+ auto r3 = request("POST", path, jDoc, ct, rkey);
|
|
|
|
|
+ ASSERT_TRUE(r3);
|
|
|
|
|
+ EXPECT_EQ(r3->status, 429) << "3rd request expected 429 (rate limited); body: " << r3->body;
|
|
|
|
|
+ // The Retry-After header must be present
|
|
|
|
|
+ EXPECT_FALSE(r3->get_header_value("Retry-After").empty()) << "429 must include Retry-After header";
|
|
|
|
|
+
|
|
|
|
|
+ // Cleanup
|
|
|
|
|
+ admin().Delete(("/api/v1/keys/" + rid).c_str());
|
|
|
|
|
+ admin().Delete((base + "/rate_coll").c_str());
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// U5d: the fixture's normal UNSCOPED key still does full CRUD on documents (regression guard).
|
|
|
|
|
+TEST_F(ApiFixture, LegacyKeyUnaffected) {
|
|
|
|
|
+ const std::string base = "/api/v1/projects/" + project_ + "/collections";
|
|
|
|
|
+ ASSERT_EQ(admin().Post(base.c_str(),
|
|
|
|
|
+ nlohmann::json{{"name","legacy_coll"},{"kind","json"}}.dump(), "application/json")->status, 201);
|
|
|
|
|
+
|
|
|
|
|
+ const std::string docs = base + "/legacy_coll/documents";
|
|
|
|
|
+
|
|
|
|
|
+ // Insert
|
|
|
|
|
+ auto ins = admin().Post(docs.c_str(),
|
|
|
|
|
+ nlohmann::json{{"data",{{"val",42}}}}.dump(), "application/json");
|
|
|
|
|
+ ASSERT_TRUE(ins); EXPECT_EQ(ins->status, 201) << ins->body;
|
|
|
|
|
+ std::string docId = nlohmann::json::parse(ins->body)["id"];
|
|
|
|
|
+
|
|
|
|
|
+ // List
|
|
|
|
|
+ auto list = admin().Get(docs.c_str());
|
|
|
|
|
+ ASSERT_TRUE(list); EXPECT_EQ(list->status, 200) << list->body;
|
|
|
|
|
+ EXPECT_GE(nlohmann::json::parse(list->body)["count"].get<int>(), 1);
|
|
|
|
|
+
|
|
|
|
|
+ // Delete
|
|
|
|
|
+ auto del = admin().Delete((docs + "/" + docId).c_str());
|
|
|
|
|
+ ASSERT_TRUE(del); EXPECT_EQ(del->status, 200) << del->body;
|
|
|
|
|
+
|
|
|
|
|
+ admin().Delete((base + "/legacy_coll").c_str());
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
// U4: admins can create/patch keys with a scope, validated.
|
|
// U4: admins can create/patch keys with a scope, validated.
|
|
|
TEST_F(ApiFixture, CreateScopedKeyAndReject) {
|
|
TEST_F(ApiFixture, CreateScopedKeyAndReject) {
|
|
|
auto c = admin();
|
|
auto c = admin();
|