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.
260 lines
8.3 KiB
Python
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()
|