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

250 lines
8.2 KiB
Python

from __future__ import annotations
import importlib
import secrets
import pytest
from flask import Flask, jsonify
from sqlalchemy import create_engine
from app.api.agent_group import agent_group_bp
from app.api.auth import auth_bp
from app.api.template import template_bp
from app.db import Base, create_session_factory
from app.services.identity import IdentityRepository, PasswordService
from app.utils.api_errors import ApiError
from app.utils.locale import t
agent_group_module = importlib.import_module("app.api.agent_group")
template_module = importlib.import_module("app.api.template")
TEST_EMAIL = "auxiliary-security@example.com"
TEST_AUTH_INPUT = "local-only-auth-input"
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: ApiError):
return jsonify(error.to_payload(t)), error.status_code
app.register_blueprint(auth_bp, url_prefix="/api/auth")
app.register_blueprint(template_bp, url_prefix="/api/template")
app.register_blueprint(agent_group_bp, url_prefix="/api/agent-group")
with session_factory() as session:
repo = IdentityRepository(session)
organization = repo.create_organization(name="Auxiliary Org", slug="auxiliary-org")
user = repo.create_user(
email=TEST_EMAIL,
password_hash=PasswordService.hash_password(TEST_AUTH_INPUT),
)
repo.create_membership(user.id, organization.id, "admin")
session.commit()
return app, engine
def login(client):
response = client.post(
"/api/auth/login",
json={"email": TEST_EMAIL, "password": TEST_AUTH_INPUT},
)
assert response.status_code == 200
return client.get_cookie("crowdsight_csrf").value
def test_template_list_requires_authentication():
app, engine = make_app()
try:
response = app.test_client().get("/api/template/list")
assert response.status_code == 401
assert response.get_json()["error_code"] == "unauthorized"
finally:
engine.dispose()
def test_agent_group_categorize_requires_authentication():
app, engine = make_app()
try:
response = app.test_client().post(
"/api/agent-group/categorize",
json={"agents": [{"name": "Alice"}]},
)
assert response.status_code == 401
assert response.get_json()["error_code"] == "unauthorized"
finally:
engine.dispose()
def test_authenticated_auxiliary_reads_and_pure_filter_remain_available():
app, engine = make_app()
try:
client = app.test_client()
csrf = login(client)
templates = client.get("/api/template/list")
assert templates.status_code == 200
assert templates.get_json()["success"] is True
filtered = client.post(
"/api/agent-group/filter",
json={
"agents": [{"agent_id": 1}],
"groups": [{"group_id": "all", "agent_indices": [0]}],
"selected_group_ids": ["all"],
},
headers={"X-CSRF-Token": csrf},
)
assert filtered.status_code == 200
assert filtered.get_json()["selected_agent_ids"] == [0]
finally:
engine.dispose()
@pytest.mark.parametrize(
("path", "payload"),
[
("/api/template/auto-select", {"text": "Alice founded Orbit."}),
("/api/agent-group/categorize", {"agents": [{"name": "Alice"}]}),
],
)
def test_auxiliary_llm_mutations_require_csrf_and_idempotency(path, payload):
app, engine = make_app()
try:
client = app.test_client()
csrf = login(client)
missing_csrf = client.post(
path,
json=payload,
headers={"Idempotency-Key": "auxiliary-mutation-1"},
)
assert missing_csrf.status_code == 403
assert missing_csrf.get_json()["error_code"] == "csrf_failed"
missing_idempotency = client.post(
path,
json=payload,
headers={"X-CSRF-Token": csrf},
)
assert missing_idempotency.status_code == 400
assert missing_idempotency.get_json()["error_code"] == "idempotency_required"
finally:
engine.dispose()
def test_every_auxiliary_route_requires_authentication():
app, engine = make_app()
try:
client = app.test_client()
routes = [
("GET", "/api/template/list", None),
("GET", "/api/template/news_event/filter-rules", None),
("POST", "/api/template/auto-select", {"text": "Alice founded Orbit."}),
("POST", "/api/agent-group/filter", {"agents": [], "groups": []}),
("POST", "/api/agent-group/categorize", {"agents": [{"name": "Alice"}]}),
]
for method, path, payload in routes:
response = client.open(path, method=method, json=payload)
assert response.status_code == 401, (method, path, response.get_json())
finally:
engine.dispose()
def test_llm_auxiliary_errors_are_safe(monkeypatch):
class ExplodingLLM:
def chat_json(self, **_kwargs):
raise RuntimeError("sensitive backend detail")
monkeypatch.setattr(template_module, "LLMClient", ExplodingLLM)
monkeypatch.setattr(agent_group_module, "LLMClient", ExplodingLLM)
app, engine = make_app()
try:
client = app.test_client()
csrf = login(client)
responses = [
client.post(
"/api/template/auto-select",
json={"text": "Alice founded Orbit."},
headers={"X-CSRF-Token": csrf, "Idempotency-Key": "safe-template-error"},
),
client.post(
"/api/agent-group/categorize",
json={"agents": [{"name": "Alice"}]},
headers={"X-CSRF-Token": csrf, "Idempotency-Key": "safe-agent-error"},
),
]
for response in responses:
body = response.get_json()
assert response.status_code == 500
assert body["error_code"] == "internal_error"
assert "sensitive backend detail" not in response.get_data(as_text=True)
finally:
engine.dispose()
def test_llm_auxiliary_mutations_replay_completed_responses(monkeypatch):
class FakeTemplateLLM:
def chat_json(self, **_kwargs):
return {
"template_id": "news_event",
"prompt": "Alice founded Orbit.",
"confidence": 0.9,
"reasoning": "The input describes a news event.",
}
class FakeAgentLLM:
def chat_json(self, **_kwargs):
return {
"groups": [
{
"group_id": "audience",
"group_name": "Audience",
"default_enabled": True,
"agent_indices": [0],
}
]
}
monkeypatch.setattr(template_module, "LLMClient", FakeTemplateLLM)
monkeypatch.setattr(agent_group_module, "LLMClient", FakeAgentLLM)
app, engine = make_app()
try:
client = app.test_client()
csrf = login(client)
cases = [
(
"/api/template/auto-select",
{"text": "Alice founded Orbit."},
"template-replay",
),
(
"/api/agent-group/categorize",
{"agents": [{"name": "Alice"}]},
"agent-replay",
),
]
for path, payload, key in cases:
headers = {"X-CSRF-Token": csrf, "Idempotency-Key": key}
first = client.post(path, json=payload, headers=headers)
second = client.post(path, json=payload, headers=headers)
assert first.status_code == 200
assert second.status_code == 200
assert second.get_json() == first.get_json()
finally:
engine.dispose()