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.
210 lines
8.0 KiB
Python
210 lines
8.0 KiB
Python
import pytest
|
|
from flask import Flask
|
|
from sqlalchemy import create_engine
|
|
|
|
from app.db import Base, create_session_factory
|
|
from app.models.task import TaskManager, TaskStatus
|
|
from app.security.policy import Role
|
|
from app.services.identity import IdentityRepository
|
|
|
|
|
|
def test_task_state_survives_task_manager_reconfiguration():
|
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
|
Base.metadata.create_all(engine)
|
|
factory = create_session_factory(engine)
|
|
try:
|
|
with factory() as session:
|
|
repo = IdentityRepository(session)
|
|
org = repo.create_organization(name="Jobs", slug="jobs-org")
|
|
user = repo.create_user(email="jobs@example.com")
|
|
repo.create_membership(user.id, org.id, Role.ADMIN)
|
|
session.commit()
|
|
|
|
TaskManager.configure(factory)
|
|
first_manager = TaskManager()
|
|
task_id = first_manager.create_task(
|
|
"durable_test",
|
|
metadata={"organization_id": org.id, "owner_user_id": user.id},
|
|
)
|
|
first_manager.update_task(
|
|
task_id,
|
|
status=TaskStatus.PROCESSING,
|
|
progress=42,
|
|
message="working",
|
|
progress_detail={"stage": "one"},
|
|
)
|
|
|
|
TaskManager.configure(factory)
|
|
restarted_manager = TaskManager()
|
|
task = restarted_manager.get_task(task_id)
|
|
assert task is not None
|
|
assert task.status is TaskStatus.PROCESSING
|
|
assert task.progress == 42
|
|
assert task.message == "working"
|
|
assert task.progress_detail == {"stage": "one"}
|
|
finally:
|
|
TaskManager.configure(None)
|
|
engine.dispose()
|
|
|
|
|
|
def test_task_manager_query_filters_are_tenant_scoped():
|
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
|
Base.metadata.create_all(engine)
|
|
factory = create_session_factory(engine)
|
|
try:
|
|
TaskManager.configure(factory)
|
|
manager = TaskManager()
|
|
task_a = manager.create_task("alpha", {"organization_id": "org-a", "owner_user_id": "user-a"})
|
|
task_b = manager.create_task("beta", {"organization_id": "org-b", "owner_user_id": "user-b"})
|
|
|
|
assert manager.get_task(task_a, organization_id="org-a") is not None
|
|
assert manager.get_task(task_b, organization_id="org-a") is None
|
|
assert manager.get_task(task_a, organization_id="org-a", owner_user_id="user-b") is None
|
|
assert [task.metadata["organization_id"] for task in manager.list_tasks(organization_id="org-a")] == ["org-a"]
|
|
assert [task.metadata["owner_user_id"] for task in manager.list_tasks(organization_id="org-a", owner_user_id="user-a")] == ["user-a"]
|
|
assert manager.list_tasks(organization_id="org-a", owner_user_id="user-b") == []
|
|
finally:
|
|
TaskManager.configure(None)
|
|
engine.dispose()
|
|
|
|
|
|
def test_task_manager_fails_closed_inside_app_without_session_factory():
|
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
|
Base.metadata.create_all(engine)
|
|
factory = create_session_factory(engine)
|
|
app = Flask("missing-session-factory")
|
|
try:
|
|
TaskManager.configure(factory)
|
|
with app.app_context():
|
|
with pytest.raises(RuntimeError, match="task_session_factory_required"):
|
|
TaskManager()
|
|
finally:
|
|
TaskManager.configure(None)
|
|
engine.dispose()
|
|
|
|
|
|
def test_task_manager_binds_to_current_app_without_cross_app_leak():
|
|
engines = []
|
|
apps = []
|
|
organization_ids = []
|
|
try:
|
|
for slug in ("app-a", "app-b"):
|
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
|
Base.metadata.create_all(engine)
|
|
factory = create_session_factory(engine)
|
|
with factory() as session:
|
|
repo = IdentityRepository(session)
|
|
organization = repo.create_organization(name=slug, slug=slug)
|
|
session.commit()
|
|
app = Flask(slug)
|
|
app.extensions["crowdsight_session_factory"] = factory
|
|
engines.append(engine)
|
|
apps.append(app)
|
|
organization_ids.append(organization.id)
|
|
|
|
TaskManager.configure(None)
|
|
with apps[0].app_context():
|
|
task_a = TaskManager().create_task(
|
|
"app_a_task",
|
|
metadata={"organization_id": organization_ids[0]},
|
|
)
|
|
with apps[1].app_context():
|
|
assert TaskManager().get_task(task_a) is None
|
|
task_b = TaskManager().create_task(
|
|
"app_b_task",
|
|
metadata={"organization_id": organization_ids[1]},
|
|
)
|
|
with apps[0].app_context():
|
|
assert TaskManager().get_task(task_a) is not None
|
|
assert TaskManager().get_task(task_b) is None
|
|
finally:
|
|
TaskManager.configure(None)
|
|
for engine in engines:
|
|
engine.dispose()
|
|
|
|
|
|
def test_reused_task_manager_fails_closed_across_app_contexts():
|
|
engines = []
|
|
apps = []
|
|
organization_ids = []
|
|
try:
|
|
for slug in ("reuse-a", "reuse-b"):
|
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
|
Base.metadata.create_all(engine)
|
|
factory = create_session_factory(engine)
|
|
with factory() as session:
|
|
repo = IdentityRepository(session)
|
|
organization = repo.create_organization(name=slug, slug=slug)
|
|
session.commit()
|
|
app = Flask(slug)
|
|
app.extensions["crowdsight_session_factory"] = factory
|
|
engines.append(engine)
|
|
apps.append(app)
|
|
organization_ids.append(organization.id)
|
|
|
|
with apps[0].app_context():
|
|
manager = TaskManager()
|
|
task_id = manager.create_task("reuse_task", {"organization_id": organization_ids[0]})
|
|
with apps[1].app_context():
|
|
with pytest.raises(RuntimeError, match="task_app_context_mismatch"):
|
|
manager.get_task(task_id)
|
|
finally:
|
|
TaskManager.configure(None)
|
|
for engine in engines:
|
|
engine.dispose()
|
|
|
|
|
|
def test_explicit_task_manager_factory_must_match_current_app():
|
|
engine_a = create_engine("sqlite+pysqlite:///:memory:")
|
|
engine_b = create_engine("sqlite+pysqlite:///:memory:")
|
|
Base.metadata.create_all(engine_a)
|
|
Base.metadata.create_all(engine_b)
|
|
factory_a = create_session_factory(engine_a)
|
|
factory_b = create_session_factory(engine_b)
|
|
app_b = Flask("explicit-b")
|
|
app_b.extensions["crowdsight_session_factory"] = factory_b
|
|
try:
|
|
with app_b.app_context():
|
|
with pytest.raises(RuntimeError, match="task_session_factory_mismatch"):
|
|
TaskManager(session_factory=factory_a)
|
|
finally:
|
|
TaskManager.configure(None)
|
|
engine_a.dispose()
|
|
engine_b.dispose()
|
|
|
|
|
|
def test_prebound_task_manager_fails_closed_inside_other_app():
|
|
engine_a = create_engine("sqlite+pysqlite:///:memory:")
|
|
engine_b = create_engine("sqlite+pysqlite:///:memory:")
|
|
Base.metadata.create_all(engine_a)
|
|
Base.metadata.create_all(engine_b)
|
|
factory_a = create_session_factory(engine_a)
|
|
factory_b = create_session_factory(engine_b)
|
|
app_b = Flask("prebound-b")
|
|
app_b.extensions["crowdsight_session_factory"] = factory_b
|
|
manager = TaskManager(session_factory=factory_a)
|
|
try:
|
|
with app_b.app_context():
|
|
with pytest.raises(RuntimeError, match="task_session_factory_mismatch"):
|
|
manager.get_task("not-in-scope")
|
|
finally:
|
|
TaskManager.configure(None)
|
|
engine_a.dispose()
|
|
engine_b.dispose()
|
|
|
|
|
|
def test_app_bound_task_manager_supports_background_use_without_context():
|
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
|
Base.metadata.create_all(engine)
|
|
factory = create_session_factory(engine)
|
|
app = Flask("background-use")
|
|
app.extensions["crowdsight_session_factory"] = factory
|
|
try:
|
|
with app.app_context():
|
|
manager = TaskManager()
|
|
task_id = manager.create_task("background_task", {"organization_id": "org-bg"})
|
|
assert manager.get_task(task_id) is not None
|
|
finally:
|
|
TaskManager.configure(None)
|
|
engine.dispose()
|