Files
microfish/backend/tests/test_durable_task_manager.py
Kunthawat Greethong 8b84378fe1 feat: SaaS foundation for CrowdSight
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.
2026-08-31 13:05:21 +07:00

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()