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.
185 lines
6.7 KiB
Python
185 lines
6.7 KiB
Python
import secrets
|
|
|
|
from flask import Flask, jsonify
|
|
from sqlalchemy import create_engine
|
|
|
|
from app.db import Base, create_session_factory
|
|
from app.security.auth import require_auth
|
|
from app.security.policy import Role
|
|
from app.services.idempotency import IdempotencyService, _request_fingerprint_payload, idempotent
|
|
from app.services.identity import IdentityRepository, PasswordService
|
|
from app.utils.api_errors import ApiError
|
|
from app.utils.locale import t
|
|
|
|
|
|
def make_app():
|
|
engine = create_engine("sqlite+pysqlite:///:memory:")
|
|
Base.metadata.create_all(engine)
|
|
session_factory = create_session_factory(engine)
|
|
app = Flask(__name__)
|
|
app.config.update(TESTING=True, SESSION_COOKIE_SECURE=False)
|
|
app.config["SECRET_KEY"] = secrets.token_hex(32)
|
|
app.extensions["crowdsight_session_factory"] = session_factory
|
|
|
|
@app.errorhandler(ApiError)
|
|
def handle_api_error(error):
|
|
return {**error.to_payload(t)}, error.status_code
|
|
|
|
@app.post("/mutate")
|
|
@require_auth
|
|
@idempotent
|
|
def mutate():
|
|
return jsonify({"success": True, "data": {"value": "created"}}), 201
|
|
|
|
@app.post("/mutate-alt")
|
|
@require_auth
|
|
@idempotent
|
|
def mutate_alt():
|
|
return jsonify({"success": True, "data": {"value": "alternate"}}), 201
|
|
|
|
with session_factory() as session:
|
|
repo = IdentityRepository(session)
|
|
org = repo.create_organization(name="Org A", slug="idempotent-org")
|
|
user = repo.create_user(
|
|
email="idempotent@example.com",
|
|
password_hash=PasswordService.hash_password("correct horse battery staple"),
|
|
)
|
|
repo.create_membership(user.id, org.id, Role.ADMIN)
|
|
session.commit()
|
|
return app, engine
|
|
|
|
|
|
def login(client):
|
|
response = client.post(
|
|
"/mutate",
|
|
json={"value": "created"},
|
|
headers={"Idempotency-Key": "mutation-1"},
|
|
)
|
|
return response
|
|
|
|
|
|
def test_idempotent_route_replays_completed_response():
|
|
app, engine = make_app()
|
|
try:
|
|
client = app.test_client()
|
|
auth_app = Flask(__name__)
|
|
# Login through the real blueprint in a small shared app is covered separately;
|
|
# seed the opaque cookie via the auth endpoint mounted on this test app.
|
|
from app.api.auth import auth_bp
|
|
app.register_blueprint(auth_bp, url_prefix="/api/auth")
|
|
login_response = client.post(
|
|
"/api/auth/login",
|
|
json={"email": "idempotent@example.com", "password": "correct horse battery staple"},
|
|
)
|
|
assert login_response.status_code == 200
|
|
csrf = client.get_cookie("crowdsight_csrf").value
|
|
headers = {"Idempotency-Key": "mutation-1", "X-CSRF-Token": csrf}
|
|
|
|
first = client.post("/mutate", json={"value": "created"}, headers=headers)
|
|
second = client.post("/mutate", json={"value": "created"}, headers=headers)
|
|
assert first.status_code == second.status_code == 201
|
|
assert first.get_json() == second.get_json()
|
|
finally:
|
|
engine.dispose()
|
|
|
|
|
|
def test_idempotent_route_rejects_same_key_for_different_body():
|
|
app, engine = make_app()
|
|
try:
|
|
client = app.test_client()
|
|
from app.api.auth import auth_bp
|
|
app.register_blueprint(auth_bp, url_prefix="/api/auth")
|
|
assert client.post(
|
|
"/api/auth/login",
|
|
json={"email": "idempotent@example.com", "password": "correct horse battery staple"},
|
|
).status_code == 200
|
|
csrf = client.get_cookie("crowdsight_csrf").value
|
|
headers = {"Idempotency-Key": "mutation-2", "X-CSRF-Token": csrf}
|
|
assert client.post("/mutate", json={"value": "a"}, headers=headers).status_code == 201
|
|
conflict = client.post("/mutate", json={"value": "b"}, headers=headers)
|
|
assert conflict.status_code == 409
|
|
assert conflict.get_json()["error_code"] == "idempotency_key_reused"
|
|
finally:
|
|
engine.dispose()
|
|
|
|
|
|
def test_multipart_file_content_affects_fingerprint():
|
|
app = Flask(__name__)
|
|
boundary = b"FixedBoundary"
|
|
|
|
def fingerprint(content):
|
|
body = (
|
|
b"--" + boundary + b"\r\n"
|
|
b'Content-Disposition: form-data; name="simulation_requirement"\r\n\r\n'
|
|
b"req\r\n"
|
|
b"--" + boundary + b"\r\n"
|
|
b'Content-Disposition: form-data; name="files"; filename="doc.txt"\r\n'
|
|
b"Content-Type: text/plain\r\n\r\n"
|
|
+ content
|
|
+ b"\r\n--"
|
|
+ boundary
|
|
+ b"--\r\n"
|
|
)
|
|
with app.test_request_context(
|
|
"/upload",
|
|
method="POST",
|
|
data=body,
|
|
content_type="multipart/form-data; boundary=FixedBoundary",
|
|
):
|
|
payload = _request_fingerprint_payload()
|
|
return IdempotencyService._request_hash(payload)
|
|
|
|
assert fingerprint(b"alpha") != fingerprint(b"bravo")
|
|
|
|
|
|
def test_all_multipart_files_affect_fingerprint():
|
|
app = Flask(__name__)
|
|
boundary = b"FixedBoundary"
|
|
|
|
def fingerprint(second_content):
|
|
first_part = (
|
|
b'Content-Disposition: form-data; name="files"; filename="first.txt"\r\n'
|
|
b"Content-Type: text/plain\r\n\r\nalpha"
|
|
)
|
|
second_part = (
|
|
b'Content-Disposition: form-data; name="files"; filename="second.txt"\r\n'
|
|
b"Content-Type: text/plain\r\n\r\n"
|
|
+ second_content
|
|
)
|
|
body = (
|
|
b"--" + boundary + b"\r\n"
|
|
+ first_part
|
|
+ b"\r\n--" + boundary + b"\r\n"
|
|
+ second_part
|
|
+ b"\r\n--" + boundary + b"--\r\n"
|
|
)
|
|
with app.test_request_context(
|
|
"/upload",
|
|
method="POST",
|
|
data=body,
|
|
content_type="multipart/form-data; boundary=FixedBoundary",
|
|
):
|
|
return IdempotencyService._request_hash(_request_fingerprint_payload())
|
|
|
|
assert fingerprint(b"bravo") != fingerprint(b"charl")
|
|
|
|
|
|
def test_idempotency_key_cannot_replay_across_routes():
|
|
app, engine = make_app()
|
|
try:
|
|
client = app.test_client()
|
|
from app.api.auth import auth_bp
|
|
app.register_blueprint(auth_bp, url_prefix="/api/auth")
|
|
assert client.post(
|
|
"/api/auth/login",
|
|
json={"email": "idempotent@example.com", "password": "correct horse battery staple"},
|
|
).status_code == 200
|
|
csrf = client.get_cookie("crowdsight_csrf").value
|
|
headers = {"Idempotency-Key": "cross-route", "X-CSRF-Token": csrf}
|
|
assert client.post("/mutate", json={"value": "created"}, headers=headers).status_code == 201
|
|
conflict = client.post("/mutate-alt", json={"value": "created"}, headers=headers)
|
|
assert conflict.status_code == 409
|
|
assert conflict.get_json()["error_code"] == "idempotency_key_reused"
|
|
finally:
|
|
engine.dispose()
|