960 lines
31 KiB
Python
960 lines
31 KiB
Python
"""Regression coverage for findings from the 2026-08-15 independent review."""
|
|
from __future__ import annotations
|
|
|
|
import io
|
|
import math
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from app.api import analytics_routes, group_routes
|
|
from app.api.helpers import ApiError
|
|
from app.config import Config
|
|
from app.services.file_parser import ParseError, parse_pdf
|
|
from app.services.groups import GroupStore
|
|
from app.services.trainee import _session_evidence
|
|
from scripts.mock_llm import MockLLM
|
|
|
|
|
|
def _headers(token: str) -> dict[str, str]:
|
|
return {"Authorization": f"Bearer {token}"}
|
|
|
|
|
|
def _setup_default_admin(user_store, login) -> str:
|
|
user_store.complete_setup(
|
|
"admin",
|
|
"post-review-admin@example.com",
|
|
"post-review-admin-password",
|
|
accepted_terms=True,
|
|
accepted_terms_at="2026-08-15T00:00:00Z",
|
|
)
|
|
return login("admin", "post-review-admin-password")["token"]
|
|
|
|
|
|
def _raw_group(group_store, *, personas=None, sales_kit=None):
|
|
group = group_store.create(
|
|
org_id="org-default",
|
|
creator_id="admin",
|
|
title="Post-review group",
|
|
)
|
|
return group_store.update(
|
|
group["id"],
|
|
status="ready",
|
|
input={"product": "CRM", "channel": "social", "language": "th"},
|
|
sales_kit=sales_kit,
|
|
personas=[] if personas is None else personas,
|
|
)
|
|
|
|
|
|
def _cross_tenant_chat_session(client, user_store, login, *, status="active"):
|
|
_setup_default_admin(user_store, login)
|
|
other_org = user_store.create_org("Other chat tenant")
|
|
trainee = user_store.create_user(
|
|
org_id=other_org["id"],
|
|
username=f"stale-chat-{status}",
|
|
password="stale-chat-password",
|
|
name="Stale Chat User",
|
|
role="user",
|
|
must_setup=False,
|
|
)
|
|
token = login(trainee["username"], "stale-chat-password")["token"]
|
|
group_store = client.application.extensions["group_store"]
|
|
group = group_store.create(
|
|
org_id="org-default",
|
|
creator_id="admin",
|
|
title="Other tenant chat group",
|
|
)
|
|
group = group_store.update(
|
|
group["id"],
|
|
status="ready",
|
|
personas=[
|
|
{
|
|
"id": "hidden-persona",
|
|
"name": "Hidden Persona",
|
|
"tier": "A",
|
|
"pains": ["must not be revealed"],
|
|
}
|
|
],
|
|
)
|
|
session_id = f"stale-chat-{status}-session"
|
|
session_store = client.application.extensions["session_store"]
|
|
session_store.sessions.create(
|
|
{
|
|
"id": session_id,
|
|
"org_id": other_org["id"],
|
|
"user_id": trainee["id"],
|
|
"group_id": group["id"],
|
|
"persona_id": "hidden-persona",
|
|
"persona_name": "Hidden Persona",
|
|
"mode": "trainee",
|
|
"status": status,
|
|
"outcome": "won" if status == "finished" else None,
|
|
"messages": [],
|
|
"internal": {},
|
|
"debrief": {"score": 99} if status == "finished" else None,
|
|
},
|
|
key=session_id,
|
|
)
|
|
return token, group, session_id
|
|
|
|
|
|
def test_chat_session_reads_recheck_group_tenant_context(client, user_store, login):
|
|
token, group, session_id = _cross_tenant_chat_session(
|
|
client, user_store, login, status="active"
|
|
)
|
|
|
|
direct = client.get(f"/api/chat/sessions/{session_id}", headers=_headers(token))
|
|
resumed = client.get(
|
|
f"/api/chat/{group['id']}/personas/hidden-persona/chat/resume",
|
|
headers=_headers(token),
|
|
)
|
|
listed = client.get("/api/chat/sessions", headers=_headers(token))
|
|
|
|
assert direct.status_code == 404, direct.get_json()
|
|
assert resumed.status_code == 404, resumed.get_json()
|
|
assert listed.status_code == 200, listed.get_json()
|
|
assert listed.get_json()["sessions"] == []
|
|
|
|
|
|
def test_chat_send_rechecks_group_tenant_context_before_persona_use(
|
|
client, user_store, login
|
|
):
|
|
token, group, _session_id = _cross_tenant_chat_session(
|
|
client, user_store, login, status="active"
|
|
)
|
|
client.application.extensions["llm"] = MockLLM()
|
|
|
|
response = client.post(
|
|
f"/api/chat/{group['id']}/personas/hidden-persona/chat/send",
|
|
json={"text": "try to use stale context"},
|
|
headers=_headers(token),
|
|
)
|
|
|
|
assert response.status_code == 404, response.get_json()
|
|
|
|
|
|
def test_chat_finish_rechecks_group_tenant_context_before_debrief(
|
|
client, user_store, login
|
|
):
|
|
token, group, _session_id = _cross_tenant_chat_session(
|
|
client, user_store, login, status="finished"
|
|
)
|
|
|
|
response = client.post(
|
|
f"/api/chat/{group['id']}/personas/hidden-persona/chat/finish",
|
|
headers=_headers(token),
|
|
)
|
|
|
|
assert response.status_code == 404, response.get_json()
|
|
|
|
|
|
def test_chat_start_rechecks_resumed_session_context(client, user_store, login):
|
|
_setup_default_admin(user_store, login)
|
|
trainee = user_store.create_user(
|
|
org_id="org-default",
|
|
username="malformed-resume-mode",
|
|
password="malformed-resume-password",
|
|
name="Malformed Resume Mode",
|
|
role="user",
|
|
must_setup=False,
|
|
)
|
|
token = login(trainee["username"], "malformed-resume-password")["token"]
|
|
group_store = client.application.extensions["group_store"]
|
|
group = group_store.create(org_id="org-default", creator_id="admin", title="Resume")
|
|
group_store.update(
|
|
group["id"],
|
|
status="ready",
|
|
personas=[{"id": "resume-persona", "name": "Resume Persona", "pains": []}],
|
|
)
|
|
session_store = client.application.extensions["session_store"]
|
|
session_store.sessions.create(
|
|
{
|
|
"id": "malformed-resume-session",
|
|
"org_id": "org-default",
|
|
"user_id": trainee["id"],
|
|
"group_id": group["id"],
|
|
"persona_id": "resume-persona",
|
|
"persona_name": "Resume Persona",
|
|
"mode": "",
|
|
"status": "active",
|
|
"outcome": None,
|
|
},
|
|
key="malformed-resume-session",
|
|
)
|
|
|
|
response = client.post(
|
|
f"/api/chat/{group['id']}/personas/resume-persona/chat/start",
|
|
json={},
|
|
headers=_headers(token),
|
|
)
|
|
|
|
assert response.status_code == 404, response.get_json()
|
|
|
|
|
|
def test_weak_area_routes_ignore_sessions_with_unauthorized_group(
|
|
client, user_store, login
|
|
):
|
|
_setup_default_admin(user_store, login)
|
|
trainee = user_store.create_user(
|
|
org_id="org-default",
|
|
username="stale-weak-area-user",
|
|
password="stale-weak-area-password",
|
|
name="Stale Weak Area User",
|
|
role="user",
|
|
must_setup=False,
|
|
)
|
|
token = login(trainee["username"], "stale-weak-area-password")["token"]
|
|
group_store = client.application.extensions["group_store"]
|
|
group = group_store.create(org_id="org-default", creator_id="admin", title="Private stale")
|
|
group_store.update(
|
|
group["id"],
|
|
status="ready",
|
|
owner_user_id="another-owner",
|
|
personas=[{"id": "stale-persona", "name": "Stale Persona", "pains": []}],
|
|
)
|
|
session_store = client.application.extensions["session_store"]
|
|
session_store.sessions.create(
|
|
{
|
|
"id": "stale-weak-area-session",
|
|
"org_id": "org-default",
|
|
"user_id": trainee["id"],
|
|
"group_id": group["id"],
|
|
"persona_id": "stale-persona",
|
|
"persona_name": "Stale Persona",
|
|
"mode": "trainee",
|
|
"status": "finished",
|
|
"outcome": "lost",
|
|
"debrief": {"score": 1, "why": "price objection"},
|
|
},
|
|
key="stale-weak-area-session",
|
|
)
|
|
|
|
response = client.get("/api/me/weak-areas", headers=_headers(token))
|
|
|
|
assert response.status_code == 200, response.get_json()
|
|
assert response.get_json()["insight"]["total_sessions"] == 0
|
|
|
|
|
|
def test_chat_direct_read_fails_closed_on_malformed_session_row(
|
|
client, user_store, login, monkeypatch
|
|
):
|
|
token = _setup_default_admin(user_store, login)
|
|
session_store = client.application.extensions["session_store"]
|
|
monkeypatch.setattr(session_store.sessions, "get_or_none", lambda _sid: object())
|
|
|
|
response = client.get("/api/chat/sessions/malformed-row", headers=_headers(token))
|
|
|
|
assert response.status_code == 404, response.get_json()
|
|
|
|
|
|
def test_private_group_is_published_atomically(tmp_path: Path, monkeypatch):
|
|
store = GroupStore(tmp_path)
|
|
|
|
def fail_if_called(*args, **kwargs):
|
|
raise AssertionError("private publication must not require a second update")
|
|
|
|
monkeypatch.setattr(store, "update", fail_if_called)
|
|
|
|
group = store.get_or_create_private_group(
|
|
org_id="org-default",
|
|
owner_user_id="trainee-1",
|
|
owner_name="Trainee",
|
|
)
|
|
|
|
assert group["owner_user_id"] == "trainee-1"
|
|
assert group["status"] == "ready"
|
|
assert group["input"]["language"] == "th"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"owner_marker, suffix",
|
|
[(None, "none"), (0, "zero"), ("", "empty")],
|
|
)
|
|
def test_chat_rejects_falsey_private_owner_markers(
|
|
client, user_store, login, owner_marker, suffix
|
|
):
|
|
_setup_default_admin(user_store, login)
|
|
trainee = user_store.create_user(
|
|
org_id="org-default",
|
|
username=f"falsey-owner-reader-{suffix}",
|
|
password="falsey-owner-reader-password",
|
|
name="Falsey Owner Reader",
|
|
role="user",
|
|
must_setup=False,
|
|
)
|
|
token = login(trainee["username"], "falsey-owner-reader-password")["token"]
|
|
group_store = client.application.extensions["group_store"]
|
|
group = group_store.create(
|
|
org_id="org-default",
|
|
creator_id="private-owner",
|
|
title="Malformed private group",
|
|
)
|
|
group_store.update(
|
|
group["id"],
|
|
status="ready",
|
|
owner_user_id=owner_marker,
|
|
personas=[{"id": "persona-1", "name": "Persona", "tier": "A"}],
|
|
)
|
|
|
|
response = client.post(
|
|
f"/api/chat/{group['id']}/personas/persona-1/chat/start",
|
|
json={},
|
|
headers=_headers(token),
|
|
)
|
|
|
|
assert response.status_code == 403, response.get_json()
|
|
|
|
|
|
def test_group_store_ignores_non_mapping_records_in_visibility_indexes(tmp_path: Path, monkeypatch):
|
|
store = GroupStore(tmp_path)
|
|
monkeypatch.setattr(store.groups, "all", lambda: [[], "malformed"])
|
|
|
|
assert store.list_for_org("org-default") == []
|
|
assert store.list_visible_to(role="admin", org_id="org-default") == []
|
|
|
|
|
|
def test_malformed_stored_auth_records_fail_closed(client, user_store, login, monkeypatch):
|
|
token = _setup_default_admin(user_store, login)
|
|
monkeypatch.setattr(user_store, "get_user_or_none", lambda _subject: ["malformed"])
|
|
|
|
response = client.get("/api/auth/me", headers=_headers(token))
|
|
|
|
assert response.status_code == 503
|
|
assert response.get_json()["error"] == "account state unavailable"
|
|
|
|
|
|
def test_malformed_stored_org_record_fails_closed(client, user_store, login, monkeypatch):
|
|
token = _setup_default_admin(user_store, login)
|
|
monkeypatch.setattr(user_store, "get_org_or_none", lambda _org_id: ["malformed"])
|
|
|
|
response = client.get("/api/auth/me", headers=_headers(token))
|
|
|
|
assert response.status_code == 503
|
|
assert response.get_json()["error"] == "account state unavailable"
|
|
|
|
|
|
def test_group_delete_keeps_cross_tenant_stale_session(client, user_store, login):
|
|
_setup_default_admin(user_store, login)
|
|
admin = user_store.create_user(
|
|
org_id="org-default",
|
|
username="post-review-delete-admin",
|
|
password="post-review-delete-admin-password",
|
|
name="Delete Admin",
|
|
role="admin",
|
|
must_setup=False,
|
|
)
|
|
token = login(admin["username"], "post-review-delete-admin-password")["token"]
|
|
user_store.create_org("Other tenant", org_id="org-other")
|
|
group_store = client.application.extensions["group_store"]
|
|
session_store = client.application.extensions["session_store"]
|
|
group = group_store.create(
|
|
org_id="org-default", creator_id=admin["id"], title="Delete me"
|
|
)
|
|
session_store.sessions.create(
|
|
{
|
|
"id": "stale-cross-tenant-session",
|
|
"org_id": "org-other",
|
|
"user_id": "someone",
|
|
"group_id": group["id"],
|
|
"persona_id": "persona-1",
|
|
"persona_name": "Persona",
|
|
"mode": "trainee",
|
|
"status": "finished",
|
|
"outcome": "won",
|
|
},
|
|
key="stale-cross-tenant-session",
|
|
)
|
|
|
|
response = client.delete(
|
|
f"/api/groups/{group['id']}", headers=_headers(token)
|
|
)
|
|
|
|
assert response.status_code == 200, response.get_json()
|
|
assert session_store.sessions.get_or_none("stale-cross-tenant-session") is not None
|
|
|
|
|
|
def test_persona_update_skips_malformed_records_before_target(
|
|
client, user_store, login
|
|
):
|
|
token = _setup_default_admin(user_store, login)
|
|
group_store = client.application.extensions["group_store"]
|
|
group = _raw_group(
|
|
group_store,
|
|
personas=[
|
|
{"name": "missing id"},
|
|
{"id": "persona-1", "name": "Before", "tier": "A"},
|
|
],
|
|
)
|
|
|
|
response = client.put(
|
|
f"/api/groups/{group['id']}/personas/persona-1",
|
|
json={"name": "After"},
|
|
headers=_headers(token),
|
|
)
|
|
|
|
assert response.status_code == 200, response.get_json()
|
|
assert response.get_json()["persona"]["name"] == "After"
|
|
|
|
|
|
def test_report_and_persona_routes_fail_closed_on_malformed_collections(
|
|
client, user_store, login, monkeypatch
|
|
):
|
|
token = _setup_default_admin(user_store, login)
|
|
group_store = client.application.extensions["group_store"]
|
|
group = _raw_group(group_store, personas=[])
|
|
group_store.update(group["id"], personas=None)
|
|
|
|
report = client.get(
|
|
f"/api/groups/{group['id']}/report?format=json",
|
|
headers=_headers(token),
|
|
)
|
|
|
|
assert report.status_code == 200, report.get_json()
|
|
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username="malformed-session-user",
|
|
password="malformed-session-password",
|
|
name="Malformed Session User",
|
|
role="user",
|
|
must_setup=False,
|
|
)
|
|
user_token = login(user["username"], "malformed-session-password")["token"]
|
|
group_store.update(
|
|
group["id"],
|
|
status="ready",
|
|
personas=[{"id": "persona-1", "name": "Persona", "tier": "A"}],
|
|
)
|
|
sessions = client.application.extensions["session_store"].sessions
|
|
monkeypatch.setattr(sessions, "all", lambda: [[], "malformed"])
|
|
|
|
personas = client.get(
|
|
f"/api/groups/{group['id']}/personas",
|
|
headers=_headers(user_token),
|
|
)
|
|
|
|
assert personas.status_code == 200, personas.get_json()
|
|
assert personas.get_json()["personas"][0]["my_outcome"] == "not_tried"
|
|
|
|
|
|
def test_trainee_outcome_requires_finished_valid_result(client, user_store, login):
|
|
_setup_default_admin(user_store, login)
|
|
group_store = client.application.extensions["group_store"]
|
|
group = _raw_group(
|
|
group_store,
|
|
personas=[{"id": "persona-1", "name": "Persona", "tier": "A"}],
|
|
)
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username="outcome-gating-user",
|
|
password="outcome-gating-password",
|
|
name="Outcome User",
|
|
role="user",
|
|
must_setup=False,
|
|
)
|
|
token = login(user["username"], "outcome-gating-password")["token"]
|
|
client.application.extensions["session_store"].sessions.create(
|
|
{
|
|
"id": "active-but-won",
|
|
"org_id": "org-default",
|
|
"user_id": user["id"],
|
|
"group_id": group["id"],
|
|
"persona_id": "persona-1",
|
|
"mode": "trainee",
|
|
"status": "active",
|
|
"outcome": "won",
|
|
},
|
|
key="active-but-won",
|
|
)
|
|
|
|
response = client.get(
|
|
f"/api/groups/{group['id']}/personas",
|
|
headers=_headers(token),
|
|
)
|
|
|
|
assert response.status_code == 200, response.get_json()
|
|
assert response.get_json()["personas"][0]["my_outcome"] == "not_tried"
|
|
|
|
|
|
def test_report_skips_nested_secret_values_and_nonfinite_numbers(
|
|
client, user_store, login
|
|
):
|
|
token = _setup_default_admin(user_store, login)
|
|
group_store = client.application.extensions["group_store"]
|
|
group = _raw_group(
|
|
group_store,
|
|
personas=[
|
|
{
|
|
"id": "persona-1",
|
|
"name": "Safe persona",
|
|
"tier": "A",
|
|
"difficulty": math.nan,
|
|
}
|
|
],
|
|
sales_kit={
|
|
"productName": "CRM",
|
|
"valueProps": [{"rootCause": "must-not-leak"}, "safe value"],
|
|
"features": [math.inf, "safe feature"],
|
|
},
|
|
)
|
|
|
|
response = client.get(
|
|
f"/api/groups/{group['id']}/report?format=json",
|
|
headers=_headers(token),
|
|
)
|
|
|
|
assert response.status_code == 200, response.get_json()
|
|
markdown = response.get_json()["markdown"]
|
|
assert "must-not-leak" not in markdown
|
|
assert "rootCause" not in markdown
|
|
assert "safe value" in markdown
|
|
assert "nan" not in markdown.lower()
|
|
assert "inf" not in markdown.lower()
|
|
|
|
|
|
def test_report_json_title_rejects_nested_title_values(client, user_store, login):
|
|
token = _setup_default_admin(user_store, login)
|
|
group_store = client.application.extensions["group_store"]
|
|
group = _raw_group(group_store, personas=[])
|
|
group_store.update(group["id"], title={"secret": "must-not-leak"})
|
|
|
|
response = client.get(
|
|
f"/api/groups/{group['id']}/report?format=json",
|
|
headers=_headers(token),
|
|
)
|
|
|
|
assert response.status_code == 200, response.get_json()
|
|
assert response.get_json()["title"] == "Sales training report"
|
|
assert "must-not-leak" not in response.get_data(as_text=True)
|
|
|
|
|
|
def test_weak_area_evidence_ignores_nonfinite_progress():
|
|
evidence = _session_evidence(
|
|
{
|
|
"debrief": {
|
|
"painProgress": {"pain_discovery": float("-inf")},
|
|
"why": "ordinary explanation",
|
|
}
|
|
}
|
|
)
|
|
|
|
assert "pain progress" not in evidence
|
|
assert "ordinary explanation" in evidence
|
|
|
|
|
|
def test_pdf_budget_counts_join_separators(monkeypatch, tmp_path: Path):
|
|
class FakePage:
|
|
rect = SimpleNamespace(x0=0, y0=0, x1=10, y1=1)
|
|
|
|
def get_text(self, *args, **kwargs):
|
|
return "aa"
|
|
|
|
class FakeDocument:
|
|
page_count = 2
|
|
|
|
def __iter__(self):
|
|
return iter((FakePage(), FakePage()))
|
|
|
|
def close(self):
|
|
pass
|
|
|
|
monkeypatch.setitem(
|
|
__import__("sys").modules,
|
|
"fitz",
|
|
SimpleNamespace(open=lambda _path: FakeDocument(), Rect=lambda *args: args),
|
|
)
|
|
monkeypatch.setattr(Config, "UPLOAD_MAX_EXTRACTED_CHARS", 4)
|
|
|
|
with pytest.raises(ParseError, match="extracted text too large"):
|
|
parse_pdf(tmp_path / "separator-boundary.pdf")
|
|
|
|
|
|
def test_analytics_counts_only_trainees(client, user_store, login):
|
|
_setup_default_admin(user_store, login)
|
|
admin = user_store.create_user(
|
|
org_id="org-default",
|
|
username="analytics-count-admin",
|
|
password="analytics-count-admin-password",
|
|
name="Analytics Admin",
|
|
role="admin",
|
|
must_setup=False,
|
|
)
|
|
user_store.create_user(
|
|
org_id="org-default",
|
|
username="analytics-count-user",
|
|
password="analytics-count-user-password",
|
|
name="Analytics User",
|
|
role="user",
|
|
must_setup=False,
|
|
)
|
|
token = login(admin["username"], "analytics-count-admin-password")["token"]
|
|
|
|
response = client.get("/api/analytics", headers=_headers(token))
|
|
|
|
assert response.status_code == 200, response.get_json()
|
|
assert response.get_json()["trainee_count"] == 1
|
|
|
|
|
|
def test_analytics_excludes_finished_sessions_owned_by_tenant_admin(
|
|
client, user_store, login
|
|
):
|
|
_setup_default_admin(user_store, login)
|
|
admin = user_store.create_user(
|
|
org_id="org-default",
|
|
username="analytics-admin-session-owner",
|
|
password="analytics-admin-session-owner-password",
|
|
name="Analytics Admin Session Owner",
|
|
role="admin",
|
|
must_setup=False,
|
|
)
|
|
token = login(admin["username"], "analytics-admin-session-owner-password")["token"]
|
|
group_store = client.application.extensions["group_store"]
|
|
session_store = client.application.extensions["session_store"]
|
|
group = group_store.create(
|
|
org_id="org-default", creator_id=admin["id"], title="Admin-owned session"
|
|
)
|
|
session_store.sessions.create(
|
|
{
|
|
"id": "analytics-admin-owned-session",
|
|
"org_id": "org-default",
|
|
"user_id": admin["id"],
|
|
"group_id": group["id"],
|
|
"mode": "trainee",
|
|
"status": "finished",
|
|
"outcome": "won",
|
|
},
|
|
key="analytics-admin-owned-session",
|
|
)
|
|
|
|
response = client.get("/api/analytics", headers=_headers(token))
|
|
|
|
assert response.status_code == 200, response.get_json()
|
|
assert response.get_json()["overall"]["total_sessions"] == 0
|
|
|
|
|
|
def test_super_admin_analytics_excludes_finished_sessions_owned_by_super_admin(
|
|
client, user_store, login
|
|
):
|
|
token = _setup_default_admin(user_store, login)
|
|
super_admin = user_store.get_user("admin")
|
|
group_store = client.application.extensions["group_store"]
|
|
session_store = client.application.extensions["session_store"]
|
|
group = group_store.create(
|
|
org_id="org-default", creator_id=super_admin["id"], title="Super-admin session"
|
|
)
|
|
session_store.sessions.create(
|
|
{
|
|
"id": "analytics-super-admin-owned-session",
|
|
"org_id": "org-default",
|
|
"user_id": super_admin["id"],
|
|
"group_id": group["id"],
|
|
"mode": "trainee",
|
|
"status": "finished",
|
|
"outcome": "lost",
|
|
},
|
|
key="analytics-super-admin-owned-session",
|
|
)
|
|
|
|
response = client.get("/api/analytics", headers=_headers(token))
|
|
|
|
assert response.status_code == 200, response.get_json()
|
|
assert response.get_json()["overall"]["total_sessions"] == 0
|
|
|
|
|
|
def test_super_admin_csv_export_excludes_non_trainee_owned_sessions(
|
|
client, user_store, login
|
|
):
|
|
token = _setup_default_admin(user_store, login)
|
|
tenant_admin = user_store.create_user(
|
|
org_id="org-default",
|
|
username="export-tenant-admin",
|
|
password="export-tenant-admin-password",
|
|
name="Export Tenant Admin",
|
|
role="admin",
|
|
must_setup=False,
|
|
)
|
|
trainee = user_store.create_user(
|
|
org_id="org-default",
|
|
username="export-trainee",
|
|
password="export-trainee-password",
|
|
name="Export Trainee",
|
|
role="user",
|
|
must_setup=False,
|
|
)
|
|
group_store = client.application.extensions["group_store"]
|
|
session_store = client.application.extensions["session_store"]
|
|
group = group_store.create(
|
|
org_id="org-default", creator_id="admin", title="Global export group"
|
|
)
|
|
for key, user_id in (
|
|
("export-super-admin-session", "admin"),
|
|
("export-tenant-admin-session", tenant_admin["id"]),
|
|
("export-trainee-session", trainee["id"]),
|
|
):
|
|
session_store.sessions.create(
|
|
{
|
|
"id": key,
|
|
"org_id": "org-default",
|
|
"user_id": user_id,
|
|
"group_id": group["id"],
|
|
"persona_id": key,
|
|
"persona_name": "Export Persona",
|
|
"mode": "trainee",
|
|
"status": "finished",
|
|
"outcome": "won",
|
|
"debrief": {"score": 80},
|
|
},
|
|
key=key,
|
|
)
|
|
|
|
response = client.get("/api/analytics/export", headers=_headers(token))
|
|
|
|
assert response.status_code == 200, response.get_json()
|
|
csv_text = response.get_data(as_text=True)
|
|
assert "export-trainee" in csv_text
|
|
assert "admin" not in csv_text
|
|
assert "export-tenant-admin" not in csv_text
|
|
|
|
|
|
def test_export_token_store_failures_are_controlled(app, monkeypatch):
|
|
class BrokenStore:
|
|
def update_if(self, *args, **kwargs):
|
|
raise OSError("storage unavailable")
|
|
|
|
monkeypatch.setattr(analytics_routes, "_export_token_store", lambda: BrokenStore())
|
|
with app.app_context():
|
|
assert analytics_routes._consume_export_token(
|
|
{"jti": "export-jti", "expires_at": 2_000_000_000, "user_id": "admin", "org_id": "org-default", "role": "admin"}
|
|
) is False
|
|
|
|
|
|
def test_csv_export_has_a_row_cap(client, user_store, login, monkeypatch):
|
|
token = _setup_default_admin(user_store, login)
|
|
group_store = client.application.extensions["group_store"]
|
|
session_store = client.application.extensions["session_store"]
|
|
group = group_store.create(
|
|
org_id="org-default", creator_id="admin", title="Export group"
|
|
)
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username="export-row-user",
|
|
password="export-row-password",
|
|
name="Export User",
|
|
role="user",
|
|
must_setup=False,
|
|
)
|
|
for sid in ("export-row-1", "export-row-2"):
|
|
session_store.sessions.create(
|
|
{
|
|
"id": sid,
|
|
"org_id": "org-default",
|
|
"user_id": user["id"],
|
|
"group_id": group["id"],
|
|
"persona_id": sid,
|
|
"persona_name": "Persona",
|
|
"mode": "trainee",
|
|
"status": "finished",
|
|
"outcome": "won",
|
|
"debrief": {"score": 80},
|
|
},
|
|
key=sid,
|
|
)
|
|
monkeypatch.setattr(Config, "ANALYTICS_EXPORT_MAX_ROWS", 1)
|
|
|
|
with client.application.app_context(), pytest.raises(ApiError, match="too many"):
|
|
analytics_routes._export_csv_for_actor(
|
|
{"id": "admin", "org_id": "org-default", "role": "admin"},
|
|
audit=False,
|
|
)
|
|
|
|
|
|
def test_csv_export_legacy_collection_does_not_materialize_where(
|
|
client, user_store, login, monkeypatch
|
|
):
|
|
_setup_default_admin(user_store, login)
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username="legacy-export-user",
|
|
password="legacy-export-password",
|
|
name="Legacy Export User",
|
|
role="user",
|
|
must_setup=False,
|
|
)
|
|
|
|
rows = [
|
|
{
|
|
"id": "legacy-export-1",
|
|
"org_id": "org-default",
|
|
"user_id": user["id"],
|
|
"group_id": "shared-group",
|
|
"mode": "trainee",
|
|
"status": "finished",
|
|
"outcome": "won",
|
|
},
|
|
{
|
|
"id": "legacy-export-2",
|
|
"org_id": "org-default",
|
|
"user_id": user["id"],
|
|
"group_id": "shared-group",
|
|
"mode": "trainee",
|
|
"status": "finished",
|
|
"outcome": "lost",
|
|
},
|
|
]
|
|
|
|
class LegacyCollection:
|
|
def iter_all(self):
|
|
return iter(rows)
|
|
|
|
def where(self, _predicate):
|
|
raise AssertionError("legacy export must not materialize where()")
|
|
|
|
fake_stores = {
|
|
"groups": SimpleNamespace(
|
|
groups=SimpleNamespace(
|
|
iter_all=lambda: iter([{"id": "shared-group", "org_id": "org-default"}])
|
|
)
|
|
),
|
|
"sessions": SimpleNamespace(sessions=LegacyCollection()),
|
|
}
|
|
monkeypatch.setattr(analytics_routes, "_stores", lambda: fake_stores)
|
|
monkeypatch.setattr(Config, "ANALYTICS_EXPORT_MAX_ROWS", 1)
|
|
|
|
with client.application.app_context(), pytest.raises(ApiError, match="too many"):
|
|
analytics_routes._export_csv_for_actor(
|
|
{"id": "admin", "org_id": "org-default", "role": "admin"},
|
|
audit=False,
|
|
)
|
|
|
|
|
|
def test_csv_export_scans_session_collection_once(
|
|
client, user_store, login, monkeypatch
|
|
):
|
|
_setup_default_admin(user_store, login)
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username="single-export-scan-user",
|
|
password="single-export-scan-password",
|
|
name="Single Export Scan User",
|
|
role="user",
|
|
must_setup=False,
|
|
)
|
|
|
|
class CountingCollection:
|
|
def __init__(self, rows):
|
|
self.rows = rows
|
|
self.calls = 0
|
|
|
|
def iter_all(self):
|
|
self.calls += 1
|
|
return iter(self.rows)
|
|
|
|
groups = CountingCollection(
|
|
[
|
|
{"id": "shared-group-1", "org_id": "org-default"},
|
|
{"id": "shared-group-2", "org_id": "org-default"},
|
|
]
|
|
)
|
|
sessions = CountingCollection(
|
|
[
|
|
{
|
|
"id": "single-export-scan-1",
|
|
"org_id": "org-default",
|
|
"user_id": user["id"],
|
|
"group_id": "shared-group-1",
|
|
"mode": "trainee",
|
|
"status": "finished",
|
|
"outcome": "won",
|
|
},
|
|
{
|
|
"id": "single-export-scan-2",
|
|
"org_id": "org-default",
|
|
"user_id": user["id"],
|
|
"group_id": "shared-group-2",
|
|
"mode": "trainee",
|
|
"status": "finished",
|
|
"outcome": "lost",
|
|
},
|
|
]
|
|
)
|
|
fake_stores = {
|
|
"groups": SimpleNamespace(groups=groups),
|
|
"sessions": SimpleNamespace(sessions=sessions),
|
|
}
|
|
monkeypatch.setattr(analytics_routes, "_stores", lambda: fake_stores)
|
|
|
|
with client.application.app_context():
|
|
response = analytics_routes._export_csv_for_actor(
|
|
{"id": "admin", "org_id": "org-default", "role": "admin"},
|
|
audit=False,
|
|
)
|
|
|
|
assert response.status_code == 200
|
|
assert sessions.calls == 1
|
|
|
|
|
|
def test_csv_export_rejects_total_scan_limit(
|
|
client, user_store, login, monkeypatch
|
|
):
|
|
_setup_default_admin(user_store, login)
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username="bounded-export-scan-user",
|
|
password="bounded-export-scan-password",
|
|
name="Bounded Export Scan User",
|
|
role="user",
|
|
must_setup=False,
|
|
)
|
|
|
|
class BoundedCollection:
|
|
def __init__(self, rows):
|
|
self.rows = rows
|
|
|
|
def iter_all(self):
|
|
return iter(self.rows)
|
|
|
|
fake_stores = {
|
|
"groups": SimpleNamespace(
|
|
groups=BoundedCollection(
|
|
[{"id": "bounded-scan-group", "org_id": "org-default"}]
|
|
)
|
|
),
|
|
"sessions": SimpleNamespace(
|
|
sessions=BoundedCollection(
|
|
[
|
|
{
|
|
"id": "bounded-scan-session",
|
|
"org_id": "org-default",
|
|
"user_id": user["id"],
|
|
"group_id": "bounded-scan-group",
|
|
"mode": "trainee",
|
|
"status": "finished",
|
|
"outcome": "won",
|
|
}
|
|
]
|
|
)
|
|
),
|
|
}
|
|
monkeypatch.setattr(analytics_routes, "_stores", lambda: fake_stores)
|
|
monkeypatch.setattr(Config, "ANALYTICS_EXPORT_MAX_SCAN_RECORDS", 1)
|
|
|
|
with client.application.app_context(), pytest.raises(ApiError, match="scan limit"):
|
|
analytics_routes._export_csv_for_actor(
|
|
{"id": "admin", "org_id": "org-default", "role": "admin"},
|
|
audit=False,
|
|
)
|
|
|
|
|
|
def test_upload_rejects_control_character_filename(client, user_store, login):
|
|
token = _setup_default_admin(user_store, login)
|
|
|
|
response = client.post(
|
|
"/api/groups",
|
|
data={"files": (io.BytesIO(b"product brief"), "bad\x01.txt")},
|
|
content_type="multipart/form-data",
|
|
headers=_headers(token),
|
|
)
|
|
|
|
assert response.status_code == 400, response.get_json()
|