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

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