|
@@ -0,0 +1,172 @@
|
|
|
|
|
+#!/usr/bin/env bash
|
|
|
|
|
+# Views over the RPC path, across project namespaces.
|
|
|
|
|
+#
|
|
|
|
|
+# Regression test for the v2.3 view-addressing break: Client::createView sent
|
|
|
|
|
+# `name` bare but `collection` qualified, while every read path qualified, so
|
|
|
|
|
+# ViewManager's cache key could never match the lookup key. Views were
|
|
|
|
|
+# unreachable via the client for EVERY project, including "default", and the
|
|
|
|
|
+# failure was silent (0 docs, no error).
|
|
|
|
|
+#
|
|
|
|
|
+# tests/test_views.cpp unit-tests applyProjection() in-process and passed
|
|
|
|
|
+# throughout. The break lives at the client/server boundary, so the test that
|
|
|
|
|
+# catches it has to cross that boundary.
|
|
|
|
|
+#
|
|
|
|
|
+# Scenarios:
|
|
|
|
|
+# 1. default project - view is queryable, projection applied
|
|
|
|
|
+# 2. custom project - same, independently
|
|
|
|
|
+# 3. name reuse - same view name in two projects stays isolated
|
|
|
|
|
+# 4. cross-project read - workspace A must not see workspace B's rows
|
|
|
|
|
+# 5. unknown name - NOT_FOUND, not a silent empty result
|
|
|
|
|
+
|
|
|
|
|
+set -euo pipefail
|
|
|
|
|
+
|
|
|
|
|
+cd "$(dirname "$0")"
|
|
|
|
|
+
|
|
|
|
|
+ROOT=/data/smartbotic-database
|
|
|
|
|
+DIR=/tmp/sbdb-views-multiproject
|
|
|
|
|
+PORT=9078
|
|
|
|
|
+
|
|
|
|
|
+rm -rf "$DIR"
|
|
|
|
|
+mkdir -p "$DIR/data"
|
|
|
|
|
+
|
|
|
|
|
+cat > "$DIR/config.json" <<EOF
|
|
|
|
|
+{
|
|
|
|
|
+ "storage": {
|
|
|
|
|
+ "data_directory": "$DIR/data",
|
|
|
|
|
+ "listeners": [
|
|
|
|
|
+ { "bind": "127.0.0.1", "port": $PORT,
|
|
|
|
|
+ "tls": { "enabled": false }, "auth": { "required": false } }
|
|
|
|
|
+ ],
|
|
|
|
|
+ "encryption": { "enabled": false },
|
|
|
|
|
+ "migrations": { "enabled": false },
|
|
|
|
|
+ "replication": { "enabled": false }
|
|
|
|
|
+ }
|
|
|
|
|
+}
|
|
|
|
|
+EOF
|
|
|
|
|
+
|
|
|
|
|
+"$ROOT/build/service/smartbotic-database" --config "$DIR/config.json" \
|
|
|
|
|
+ > "$DIR/server.log" 2>&1 &
|
|
|
|
|
+PID=$!
|
|
|
|
|
+trap "kill $PID 2>/dev/null || true" EXIT
|
|
|
|
|
+sleep 3
|
|
|
|
|
+
|
|
|
|
|
+cat > "$DIR/driver.cpp" <<'CPP'
|
|
|
|
|
+#include <smartbotic/database/client.hpp>
|
|
|
|
|
+#include <iostream>
|
|
|
|
|
+#include <string>
|
|
|
|
|
+
|
|
|
|
|
+using smartbotic::database::Client;
|
|
|
|
|
+
|
|
|
|
|
+static int failures = 0;
|
|
|
|
|
+
|
|
|
|
|
+static void check(bool ok, const std::string& what) {
|
|
|
|
|
+ std::cout << (ok ? " PASS " : " FAIL ") << what << "\n";
|
|
|
|
|
+ if (!ok) ++failures;
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+// Each workspace gets its own client, its own collection, and a view named
|
|
|
|
|
+// "adults" - deliberately the SAME view name in every project, so a global
|
|
|
|
|
+// registry would collide and a leaking one would cross-serve.
|
|
|
|
|
+static Client makeClient(const std::string& project) {
|
|
|
|
|
+ Client::Config cfg;
|
|
|
|
|
+ cfg.address = "127.0.0.1:9078";
|
|
|
|
|
+ cfg.project = project;
|
|
|
|
|
+ return Client(cfg);
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+static void seed(Client& c, const std::string& coll, const std::string& who) {
|
|
|
|
|
+ c.createCollection(coll);
|
|
|
|
|
+ c.insert(coll, {{"name", who + "_adult"}, {"age", 30}, {"secret", "classified"}});
|
|
|
|
|
+ c.insert(coll, {{"name", who + "_child"}, {"age", 12}, {"secret", "classified"}});
|
|
|
|
|
+}
|
|
|
|
|
+
|
|
|
|
|
+int main() {
|
|
|
|
|
+ // ---- scenario 1 + 2: view is queryable in default AND a custom project ----
|
|
|
|
|
+ for (const std::string project : {std::string("default"), std::string("acme")}) {
|
|
|
|
|
+ std::cout << "\n[project = " << project << "]\n";
|
|
|
|
|
+ Client c = makeClient(project);
|
|
|
|
|
+ if (!c.connect()) { check(false, "connect"); continue; }
|
|
|
|
|
+
|
|
|
|
|
+ seed(c, "users", project);
|
|
|
|
|
+ check(c.createView("adults", "users", {"name", "age"}, {},
|
|
|
|
|
+ {Client::Filter::gt("age", 18)}),
|
|
|
|
|
+ "createView(\"adults\")");
|
|
|
|
|
+
|
|
|
|
|
+ auto rows = c.find("adults", Client::QueryOptions{});
|
|
|
|
|
+ check(rows.size() == 1, "find(\"adults\") returns 1 row (got " +
|
|
|
|
|
+ std::to_string(rows.size()) + ")");
|
|
|
|
|
+ if (rows.size() == 1) {
|
|
|
|
|
+ check(rows[0].contains("name"), "projection kept \"name\"");
|
|
|
|
|
+ check(!rows[0].contains("secret"), "projection dropped \"secret\"");
|
|
|
|
|
+ check(rows[0]["name"] == project + "_adult",
|
|
|
|
|
+ "row belongs to this project");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // listViews must round-trip the name the caller supplied.
|
|
|
|
|
+ auto views = c.listViews();
|
|
|
|
|
+ bool found = false;
|
|
|
|
|
+ for (const auto& v : views) if (v.name == "adults") found = true;
|
|
|
|
|
+ check(found, "listViews() reports \"adults\" un-prefixed");
|
|
|
|
|
+ check(views.size() == 1, "listViews() shows only this project's views (got " +
|
|
|
|
|
+ std::to_string(views.size()) + ")");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- scenario 3 + 4: isolation between two workspaces ----
|
|
|
|
|
+ std::cout << "\n[isolation]\n";
|
|
|
|
|
+ Client a = makeClient("default");
|
|
|
|
|
+ Client b = makeClient("acme");
|
|
|
|
|
+ if (a.connect() && b.connect()) {
|
|
|
|
|
+ auto ra = a.find("adults", Client::QueryOptions{});
|
|
|
|
|
+ auto rb = b.find("adults", Client::QueryOptions{});
|
|
|
|
|
+ check(ra.size() == 1 && rb.size() == 1, "both workspaces resolve their own \"adults\"");
|
|
|
|
|
+ if (ra.size() == 1 && rb.size() == 1) {
|
|
|
|
|
+ check(ra[0]["name"] != rb[0]["name"],
|
|
|
|
|
+ "same view name serves different rows per project");
|
|
|
|
|
+ check(ra[0]["name"] == "default_adult" && rb[0]["name"] == "acme_adult",
|
|
|
|
|
+ "no cross-project bleed");
|
|
|
|
|
+ }
|
|
|
|
|
+ } else {
|
|
|
|
|
+ check(false, "isolation clients connected");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ // ---- scenario 5: unknown name is an error, not a silent empty ----
|
|
|
|
|
+ std::cout << "\n[unknown name]\n";
|
|
|
|
|
+ Client c = makeClient("acme");
|
|
|
|
|
+ if (c.connect()) {
|
|
|
|
|
+ bool threw = false;
|
|
|
|
|
+ try {
|
|
|
|
|
+ auto rows = c.find("no_such_thing", Client::QueryOptions{});
|
|
|
|
|
+ // If it does not throw, an empty result is the old silent failure.
|
|
|
|
|
+ check(false, "find on unknown name signalled an error (got " +
|
|
|
|
|
+ std::to_string(rows.size()) + " rows, no error)");
|
|
|
|
|
+ } catch (const std::exception&) {
|
|
|
|
|
+ threw = true;
|
|
|
|
|
+ }
|
|
|
|
|
+ if (threw) check(true, "find on unknown name signalled an error");
|
|
|
|
|
+ }
|
|
|
|
|
+
|
|
|
|
|
+ std::cout << "\n" << (failures ? "FAILED: " + std::to_string(failures) + " check(s)"
|
|
|
|
|
+ : "ALL CHECKS PASSED") << "\n";
|
|
|
|
|
+ return failures ? 1 : 0;
|
|
|
|
|
+}
|
|
|
|
|
+CPP
|
|
|
|
|
+
|
|
|
|
|
+g++ -std=c++20 -O2 \
|
|
|
|
|
+ -I"$ROOT/client/include" \
|
|
|
|
|
+ "$DIR/driver.cpp" \
|
|
|
|
|
+ -L"$ROOT/build/client" -lsmartbotic-db-client \
|
|
|
|
|
+ $(pkg-config --libs grpc++) \
|
|
|
|
|
+ -lspdlog -lfmt -pthread \
|
|
|
|
|
+ -Wl,-rpath,"$ROOT/build/client" \
|
|
|
|
|
+ -o "$DIR/driver"
|
|
|
|
|
+
|
|
|
|
|
+set +e
|
|
|
|
|
+"$DIR/driver"
|
|
|
|
|
+RC=$?
|
|
|
|
|
+set -e
|
|
|
|
|
+
|
|
|
|
|
+echo ""
|
|
|
|
|
+echo "=== server log: view lines ==="
|
|
|
|
|
+grep -iE "ViewManager" "$DIR/server.log" | tail -10 || echo "(none)"
|
|
|
|
|
+
|
|
|
|
|
+exit $RC
|