Elevate MiroFish/CrowdSight from single-container dev to a SaaS foundation: - Local memory backend (Zep-compatible): memory services/models, local graph builder + updater, AgentActivity seam, import-boundary isolation; Zep stays default, local is opt-in behind MEMORY_BACKEND. Semantic parity not yet proven. - Durable product persistence: projects/simulations/reports schema (migration 0007) + tenant/owner-scoped ProductRepository + dual-write + scoped_project read-first + ArtifactStore abstraction; durable JobQueue + worker.py. - SaaS hardening: durable RateLimiter (wired to login), UsageService (LLM accounting), redacted AuditService, idempotency, CORS allowlist, safe API errors, single-use PasswordResetService + endpoints (covers invite-pending). - Exactly 3 roles (super_admin/admin/user) with tenant authz policy. - Admin UI: GET/POST/PATCH /api/admin/users + GET/PUT /api/admin/settings (super-admin only, encrypted/masked); AdminView.vue + SettingsView.vue with admin/super-admin route guards, th/en i18n. - Production deploy topology: multi-stage Dockerfile (frontend build + gunicorn wsgi + nginx SPA-proxy + supervisord worker), backend/wsgi.py, gunicorn dep. Backend 197 passed; frontend 10 tests + build green. ruff unavailable (gap). No commit of credentials; secrets handled via env/.env.example. Deferred: Zep semantic A/B parity, object storage cutover, mobile QA, EasyPanel container build of deploy topology.
202 lines
7.0 KiB
Python
202 lines
7.0 KiB
Python
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
from flask import Flask, g
|
|
|
|
import app.api.graph as graph_api
|
|
from app.api import graph_bp
|
|
from app.config import Config
|
|
from app.models.project import ProjectManager
|
|
from app.security.policy import Role
|
|
from app.services.local_graph_builder import LocalGraphBuilderService
|
|
|
|
|
|
class _LocalGraphSpy:
|
|
instances = []
|
|
|
|
def __init__(self, session_factory, **kwargs):
|
|
self.session_factory = session_factory
|
|
self.kwargs = kwargs
|
|
self.calls = []
|
|
self.__class__.instances.append(self)
|
|
|
|
def get_graph_data(self, graph_id):
|
|
self.calls.append(("get_graph_data", graph_id))
|
|
return {"graph_id": graph_id, "node_count": 0, "edge_count": 0, "nodes": [], "edges": []}
|
|
|
|
def delete_graph(self, graph_id):
|
|
self.calls.append(("delete_graph", graph_id))
|
|
|
|
|
|
def make_app():
|
|
app = Flask(__name__)
|
|
app.config.update(TESTING=True, SECRET_KEY="unit-test")
|
|
app.extensions["crowdsight_session_factory"] = lambda: None
|
|
app.register_blueprint(graph_bp, url_prefix="/api/graph")
|
|
return app
|
|
|
|
|
|
def set_actor():
|
|
g.auth_context = SimpleNamespace(
|
|
user=SimpleNamespace(id="user-a"),
|
|
membership=SimpleNamespace(role=Role.USER),
|
|
organization=SimpleNamespace(id="org-a"),
|
|
)
|
|
|
|
|
|
def test_delete_project_rejects_out_of_scope_project_before_delete(monkeypatch):
|
|
app = make_app()
|
|
delete_calls = []
|
|
monkeypatch.setattr(graph_api, "_scoped_project", lambda project_id: None)
|
|
monkeypatch.setattr(
|
|
ProjectManager,
|
|
"delete_project",
|
|
lambda project_id, **kwargs: delete_calls.append((project_id, kwargs)) or True,
|
|
)
|
|
|
|
with app.test_request_context("/api/graph/project/project-b", method="DELETE"):
|
|
set_actor()
|
|
result = graph_api.delete_project.__wrapped__("project-b")
|
|
|
|
if isinstance(result, tuple):
|
|
response, status = result
|
|
else:
|
|
response, status = result, 200
|
|
assert status == 404
|
|
assert response.get_json()["success"] is False
|
|
assert delete_calls == []
|
|
|
|
|
|
def test_local_graph_data_route_uses_local_adapter_without_constructing_zep(monkeypatch):
|
|
app = make_app()
|
|
old_backend = Config.MEMORY_BACKEND
|
|
old_key = Config.ZEP_API_KEY
|
|
try:
|
|
monkeypatch.setattr(Config, "MEMORY_BACKEND", "local")
|
|
monkeypatch.setattr(Config, "ZEP_API_KEY", None)
|
|
monkeypatch.setattr(
|
|
graph_api,
|
|
"_scoped_graph",
|
|
lambda graph_id: SimpleNamespace(project_id="project-local"),
|
|
)
|
|
monkeypatch.setattr(graph_api, "LocalGraphBuilderService", _LocalGraphSpy)
|
|
monkeypatch.setattr(
|
|
graph_api,
|
|
"GraphBuilderService",
|
|
lambda **kwargs: pytest.fail("local graph read must not construct Zep"),
|
|
)
|
|
_LocalGraphSpy.instances.clear()
|
|
|
|
with app.test_request_context("/api/graph/data/graph-local"):
|
|
set_actor()
|
|
response = graph_api.get_graph_data.__wrapped__("graph-local")
|
|
|
|
if isinstance(response, tuple):
|
|
assert response[1] == 200
|
|
payload = response[0].get_json()
|
|
else:
|
|
payload = response.get_json()
|
|
assert payload["success"] is True
|
|
assert payload["data"]["graph_id"] == "graph-local"
|
|
assert _LocalGraphSpy.instances[0].kwargs["organization_id"] == "org-a"
|
|
assert _LocalGraphSpy.instances[0].kwargs["project_id"] == "project-local"
|
|
assert _LocalGraphSpy.instances[0].calls == [("get_graph_data", "graph-local")]
|
|
finally:
|
|
Config.MEMORY_BACKEND = old_backend
|
|
Config.ZEP_API_KEY = old_key
|
|
|
|
|
|
def test_local_graph_delete_route_uses_local_adapter_without_constructing_zep(monkeypatch):
|
|
app = make_app()
|
|
old_backend = Config.MEMORY_BACKEND
|
|
old_key = Config.ZEP_API_KEY
|
|
try:
|
|
monkeypatch.setattr(Config, "MEMORY_BACKEND", "local")
|
|
monkeypatch.setattr(Config, "ZEP_API_KEY", None)
|
|
monkeypatch.setattr(
|
|
graph_api,
|
|
"_scoped_graph",
|
|
lambda graph_id: SimpleNamespace(project_id="project-local"),
|
|
)
|
|
monkeypatch.setattr(graph_api, "LocalGraphBuilderService", _LocalGraphSpy)
|
|
monkeypatch.setattr(
|
|
graph_api,
|
|
"GraphBuilderService",
|
|
lambda **kwargs: pytest.fail("local graph delete must not construct Zep"),
|
|
)
|
|
_LocalGraphSpy.instances.clear()
|
|
|
|
with app.test_request_context("/api/graph/delete/graph-local", method="DELETE"):
|
|
set_actor()
|
|
response = graph_api.delete_graph.__wrapped__("graph-local")
|
|
|
|
payload = response[0].get_json() if isinstance(response, tuple) else response.get_json()
|
|
assert payload["success"] is True
|
|
assert _LocalGraphSpy.instances[0].calls == [("delete_graph", "graph-local")]
|
|
finally:
|
|
Config.MEMORY_BACKEND = old_backend
|
|
Config.ZEP_API_KEY = old_key
|
|
|
|
|
|
def test_local_graph_builder_deletes_only_the_scoped_graph():
|
|
from sqlalchemy import create_engine
|
|
from app.db import Base, create_session_factory
|
|
from app.models.memory import MemoryEdge, MemoryGraph, MemoryNode
|
|
|
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
|
Base.metadata.create_all(engine)
|
|
session_factory = create_session_factory(engine)
|
|
try:
|
|
builder = LocalGraphBuilderService(
|
|
session_factory,
|
|
organization_id="org-a",
|
|
project_id="project-a",
|
|
extraction_client=object(),
|
|
)
|
|
graph_id = builder.create_graph(name="Local")
|
|
|
|
with session_factory() as session:
|
|
first = MemoryNode(
|
|
id="node-a",
|
|
graph_id=graph_id,
|
|
canonical_name="A",
|
|
normalized_name="a",
|
|
)
|
|
second = MemoryNode(
|
|
id="node-b",
|
|
graph_id=graph_id,
|
|
canonical_name="B",
|
|
normalized_name="b",
|
|
)
|
|
session.add_all([first, second])
|
|
session.flush()
|
|
session.add(
|
|
MemoryEdge(
|
|
id="edge-ab",
|
|
graph_id=graph_id,
|
|
source_node_id=first.id,
|
|
target_node_id=second.id,
|
|
relation="RELATED",
|
|
fact="A is related to B.",
|
|
)
|
|
)
|
|
session.commit()
|
|
|
|
other_builder = LocalGraphBuilderService(
|
|
session_factory,
|
|
organization_id="org-b",
|
|
project_id="project-b",
|
|
extraction_client=object(),
|
|
)
|
|
with pytest.raises(ValueError, match="memory_graph_not_found"):
|
|
other_builder.delete_graph(graph_id)
|
|
|
|
builder.delete_graph(graph_id)
|
|
with session_factory() as session:
|
|
assert session.get(MemoryGraph, graph_id) is None
|
|
assert session.get(MemoryNode, "node-a") is None
|
|
assert session.get(MemoryNode, "node-b") is None
|
|
assert session.get(MemoryEdge, "edge-ab") is None
|
|
finally:
|
|
engine.dispose()
|