Files
microfish/backend/tests/test_saas_operations_schema.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

260 lines
8.3 KiB
Python

from datetime import datetime, timedelta, timezone
from pathlib import Path
import pytest
from alembic import command
from alembic.config import Config
from sqlalchemy import inspect
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm import Session
from app.db import Base, create_database_engine
from app.models.operations import AuditLog, IdempotencyRecord, Job, JobStatus
from app.security.policy import Role
from app.services.idempotency import IdempotencyConflict, IdempotencyService
from app.services.identity import IdentityRepository
def make_session():
engine = create_database_engine("sqlite+pysqlite:///:memory:")
Base.metadata.create_all(engine)
return engine, Session(engine)
def test_operations_migrations_round_trip_and_match_models(tmp_path, monkeypatch):
database_url = f"sqlite+pysqlite:///{tmp_path / 'migration-roundtrip.db'}"
monkeypatch.setenv("DATABASE_URL", database_url)
alembic_config = Config(str(Path(__file__).resolve().parents[1] / "alembic.ini"))
alembic_config.set_main_option("sqlalchemy.url", database_url)
command.upgrade(alembic_config, "head")
engine = create_database_engine(database_url)
try:
inspector = inspect(engine)
assert {"jobs", "idempotency_records", "audit_logs"}.issubset(
set(inspector.get_table_names())
)
job_columns = {column["name"] for column in inspector.get_columns("jobs")}
assert {"message", "result", "progress_detail", "metadata"}.issubset(job_columns)
job_indexes = {index["name"] for index in inspector.get_indexes("jobs")}
assert {"ix_jobs_org_status_created", "ix_jobs_org_owner"}.issubset(job_indexes)
job_foreign_keys = {
(tuple(foreign_key["constrained_columns"]), tuple(foreign_key["referred_columns"]))
for foreign_key in inspector.get_foreign_keys("jobs")
}
assert (("organization_id",), ("id",)) in job_foreign_keys
assert (("owner_user_id",), ("id",)) in job_foreign_keys
finally:
engine.dispose()
command.downgrade(alembic_config, "0003_memory")
engine = create_database_engine(database_url)
try:
assert "jobs" not in set(inspect(engine).get_table_names())
finally:
engine.dispose()
command.upgrade(alembic_config, "head")
command.check(alembic_config)
def test_sqlite_engine_enables_foreign_keys():
engine = create_database_engine("sqlite+pysqlite:///:memory:")
try:
with engine.connect() as connection:
assert connection.exec_driver_sql("PRAGMA foreign_keys").scalar() == 1
finally:
engine.dispose()
def test_operations_reject_unknown_organization_reference():
engine, session = make_session()
try:
session.add(
Job(
organization_id="org_missing",
operation="graph.build",
status=JobStatus.QUEUED,
)
)
with pytest.raises(IntegrityError):
session.commit()
finally:
session.rollback()
session.close()
engine.dispose()
def test_operations_enforce_owner_fk_and_delete_actions():
engine, session = make_session()
try:
repo = IdentityRepository(session)
org = repo.create_organization(name="FK Org", slug="fk-org")
user = repo.create_user(email="fk@example.com")
session.commit()
session.add(
Job(
organization_id=org.id,
owner_user_id="usr_missing",
operation="graph.build",
status=JobStatus.QUEUED,
)
)
with pytest.raises(IntegrityError):
session.commit()
session.rollback()
user_job = Job(
organization_id=org.id,
owner_user_id=user.id,
operation="graph.build",
status=JobStatus.QUEUED,
)
org_job = Job(
organization_id=org.id,
operation="report.generate",
status=JobStatus.QUEUED,
)
session.add_all([user_job, org_job])
session.commit()
user_job_id = user_job.id
org_job_id = org_job.id
session.delete(user)
session.commit()
persisted_user_job = session.get(Job, user_job_id)
assert persisted_user_job is not None
assert persisted_user_job.owner_user_id is None
session.delete(org)
session.commit()
assert session.get(Job, org_job_id) is None
finally:
session.close()
engine.dispose()
def seed_membership(session):
repo = IdentityRepository(session)
org = repo.create_organization(name="Org A", slug="ops-org")
user = repo.create_user(email="ops@example.com")
membership = repo.create_membership(user.id, org.id, Role.ADMIN)
session.commit()
return org, user, membership
def test_operations_schema_has_durable_tables_and_tenant_indexes():
engine, session = make_session()
try:
tables = set(inspect(engine).get_table_names())
assert {"jobs", "idempotency_records", "audit_logs"}.issubset(tables)
assert any(
"organization_id" in index["column_names"]
for index in inspect(engine).get_indexes("jobs")
)
finally:
session.close()
engine.dispose()
def test_job_and_audit_records_keep_org_and_owner_scope():
engine, session = make_session()
try:
org, user, _membership = seed_membership(session)
job = Job(
organization_id=org.id,
owner_user_id=user.id,
operation="graph.build",
status=JobStatus.QUEUED,
)
audit = AuditLog(
organization_id=org.id,
actor_user_id=user.id,
action="job.created",
target_type="job",
target_id=job.id,
details={"safe": True},
)
session.add_all([job, audit])
session.commit()
assert job.organization_id == org.id
assert job.owner_user_id == user.id
assert audit.organization_id == org.id
assert audit.details == {"safe": True}
finally:
session.close()
engine.dispose()
def test_idempotency_reservation_replays_same_request_and_rejects_hash_conflict():
engine, session = make_session()
try:
org, user, _membership = seed_membership(session)
service = IdempotencyService(session)
first = service.reserve(
organization_id=org.id,
user_id=user.id,
key="key-1",
request_body={"project_name": "A"},
)
session.commit()
assert first.is_new is True
replay = service.reserve(
organization_id=org.id,
user_id=user.id,
key="key-1",
request_body={"project_name": "A"},
)
assert replay.is_new is False
assert replay.record.id == first.record.id
try:
service.reserve(
organization_id=org.id,
user_id=user.id,
key="key-1",
request_body={"project_name": "B"},
)
except IdempotencyConflict:
pass
else:
raise AssertionError("same idempotency key accepted a different request")
finally:
session.close()
engine.dispose()
def test_idempotency_is_tenant_and_user_scoped():
engine, session = make_session()
try:
repo = IdentityRepository(session)
org_a = repo.create_organization(name="A", slug="org-a-ops")
org_b = repo.create_organization(name="B", slug="org-b-ops")
user_a = repo.create_user(email="a-ops@example.com")
user_b = repo.create_user(email="b-ops@example.com")
repo.create_membership(user_a.id, org_a.id, Role.ADMIN)
repo.create_membership(user_b.id, org_b.id, Role.ADMIN)
session.commit()
service = IdempotencyService(session)
a = service.reserve(
organization_id=org_a.id,
user_id=user_a.id,
key="shared-key",
request_body={"x": 1},
)
b = service.reserve(
organization_id=org_b.id,
user_id=user_b.id,
key="shared-key",
request_body={"x": 1},
)
assert a.is_new is True
assert b.is_new is True
assert a.record.id != b.record.id
finally:
session.close()
engine.dispose()