- Demo accounts: super_admin-only provisioning into isolated DEMO_ORG_ID tenant, 30-day UTC trial on first login, revocable, one-time credential delivery via optional SES/webhook (never persisted). Adds boto3 dependency. - Analytics/report/export/privacy: shared bounded scan budget across users/groups/ sessions, tenant-consistent session/user/group joins, scalar-only CSV export (no nested persisted-value stringification). - Ownership/tenant isolation: canonical owner-tenant predicate for list/read/chat; client sees is_owned only, never owner_user_id. - Lifecycle/races: status transition validation, analyzing is an in-progress gate (no duplicate reanalysis), structured-ready publication, stale-variant revalidation. - Auth/setup/consent/JWT/OAuth/config: fail-closed consent, bounded JWT lifetime, provider-subject atomic OAuth identity, repeated-secret rejection, strict Persona trait validation. - Chat/session/privacy: pre-seller opener redaction, corrupt-session recovery, role-aware completed-chat dashboard routing. - Frontend: Training→product→personas→practice flow, demo/role/demo guards, is_owned-based ownership display, 320×568 and 500×768 responsive E2E. - 8 independent exact-five-key review scopes passed; backend 509, frontend 26, production build 1775 modules, isolated E2E 15.
568 lines
19 KiB
Python
568 lines
19 KiB
Python
"""Regression tests for group response redaction."""
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
|
|
from app.services.groups import GroupStore
|
|
from scripts.mock_llm import MockLLM
|
|
|
|
|
|
def _headers(token: str) -> dict[str, str]:
|
|
return {"Authorization": f"Bearer {token}"}
|
|
|
|
|
|
def _setup_admin(user_store, login):
|
|
user_store.complete_setup(
|
|
"admin",
|
|
"admin@example.com",
|
|
"admin-ready-password",
|
|
accepted_terms=True,
|
|
accepted_terms_at="2026-08-13T00:00:00Z",
|
|
)
|
|
return login("admin", "admin-ready-password")["token"]
|
|
|
|
|
|
def _ready_group(client, token):
|
|
client.application.extensions["llm"] = MockLLM()
|
|
headers = _headers(token)
|
|
created = client.post(
|
|
"/api/groups", json={"product": "CRM", "segment": "SME"}, headers=headers
|
|
)
|
|
assert created.status_code == 201, created.get_json()
|
|
gid = created.get_json()["group"]["id"]
|
|
analyzed = client.post(f"/api/groups/{gid}/analyze", headers=headers)
|
|
assert analyzed.status_code == 200, analyzed.get_json()
|
|
return gid, analyzed.get_json()
|
|
|
|
|
|
def test_cross_tenant_group_ids_are_indistinguishable_from_missing_groups(
|
|
client, user_store, login
|
|
):
|
|
user_store.create_org("Organization B", org_id="org-b")
|
|
group_store = client.application.extensions["group_store"]
|
|
group = group_store.create(
|
|
org_id="org-b", creator_id="org-b-user", title="Shared tenant group"
|
|
)
|
|
private_group = group_store.create(
|
|
org_id="org-b",
|
|
creator_id="org-b-user",
|
|
owner_user_id="org-b-user",
|
|
title="Private tenant group",
|
|
)
|
|
admin = user_store.create_user(
|
|
org_id="org-default",
|
|
username="cross-tenant-group-reader",
|
|
password="cross-tenant-group-reader-password",
|
|
name="Cross Tenant Group Reader",
|
|
role="admin",
|
|
must_setup=False,
|
|
)
|
|
token = login(admin["username"], "cross-tenant-group-reader-password")["token"]
|
|
|
|
response = client.get(
|
|
f"/api/groups/{group['id']}", headers=_headers(token)
|
|
)
|
|
|
|
assert response.status_code == 404, response.get_json()
|
|
assert response.get_json() == {"error": "group not found"}
|
|
private_response = client.get(
|
|
f"/api/groups/{private_group['id']}", headers=_headers(token)
|
|
)
|
|
assert private_response.status_code == 404, private_response.get_json()
|
|
assert private_response.get_json() == {"error": "group not found"}
|
|
|
|
|
|
def test_admin_analyze_get_and_list_use_same_redaction(client, user_store, login):
|
|
_setup_admin(user_store, login)
|
|
|
|
admin = user_store.create_user(
|
|
org_id="org-default",
|
|
username="review-admin",
|
|
password="review-admin-password",
|
|
name="Review Admin",
|
|
role="admin",
|
|
email="review-admin@example.com",
|
|
must_setup=False,
|
|
)
|
|
admin_token = login(admin["username"], "review-admin-password")["token"]
|
|
gid, analyzed = _ready_group(client, admin_token)
|
|
headers = _headers(admin_token)
|
|
|
|
analyze_group = analyzed["group"]
|
|
get_group = client.get(f"/api/groups/{gid}", headers=headers).get_json()["group"]
|
|
listed = client.get(f"/api/groups/{gid}/personas", headers=headers).get_json()["personas"]
|
|
|
|
assert analyze_group.get("sales_kit") is None
|
|
assert analyze_group.get("report") is None
|
|
assert "creator_id" not in analyze_group
|
|
assert "owner_user_id" not in analyze_group
|
|
assert get_group.get("sales_kit") is None
|
|
assert get_group.get("report") is None
|
|
assert "creator_id" not in get_group
|
|
assert "owner_user_id" not in get_group
|
|
for persona in (analyze_group["personas"][0], get_group["personas"][0], listed[0]):
|
|
assert "pains" not in persona
|
|
assert "tolerance" not in persona
|
|
assert "negotiation_levers" not in persona
|
|
|
|
|
|
def test_admin_response_does_not_expose_future_secret_fields(
|
|
client, user_store, login
|
|
):
|
|
super_token = _setup_admin(user_store, login)
|
|
gid, analyzed = _ready_group(client, super_token)
|
|
source_id = analyzed["personas"][0]["id"]
|
|
stored = user_store # keep fixture dependency explicit for isolated data
|
|
group_store = client.application.extensions["group_store"]
|
|
group = group_store.get(gid)
|
|
group["personas"][0]["future_hidden_signal"] = {"secret": "must-not-leak"}
|
|
group_store.groups.replace(gid, group)
|
|
|
|
admin = stored.create_user(
|
|
org_id="org-default",
|
|
username="future-field-admin",
|
|
password="future-field-admin-password",
|
|
name="Future Field Admin",
|
|
role="admin",
|
|
email="future-field-admin@example.com",
|
|
must_setup=False,
|
|
)
|
|
token = login(admin["username"], "future-field-admin-password")["token"]
|
|
|
|
responses = [
|
|
client.get(f"/api/groups/{gid}", headers=_headers(token)).get_json()["group"]["personas"][0],
|
|
client.get(
|
|
f"/api/groups/{gid}/personas", headers=_headers(token)
|
|
).get_json()["personas"][0],
|
|
client.get(
|
|
f"/api/groups/{gid}/personas/{source_id}", headers=_headers(token)
|
|
).get_json()["persona"],
|
|
]
|
|
|
|
assert all("future_hidden_signal" not in persona for persona in responses)
|
|
|
|
|
|
def test_group_metadata_storage_error_is_not_returned_to_client(
|
|
client, user_store, login, monkeypatch
|
|
):
|
|
super_token = _setup_admin(user_store, login)
|
|
group_store = client.application.extensions["group_store"]
|
|
group = group_store.create(
|
|
org_id="org-default", creator_id="admin", title="Storage error group"
|
|
)
|
|
|
|
def fail_update(*args, **kwargs):
|
|
raise ValueError("/private/storage/path: internal detail")
|
|
|
|
monkeypatch.setattr(group_store, "update", fail_update)
|
|
response = client.patch(
|
|
f"/api/groups/{group['id']}/metadata",
|
|
json={"title": "New title"},
|
|
headers=_headers(super_token),
|
|
)
|
|
|
|
assert response.status_code == 400, response.get_json()
|
|
assert response.get_json() == {"error": "group update failed"}
|
|
assert "/private/storage/path" not in response.get_data(as_text=True)
|
|
|
|
|
|
def test_tenant_admin_group_view_hides_uploaded_source_payload(
|
|
client, user_store, login
|
|
):
|
|
_setup_admin(user_store, login)
|
|
group_store = client.application.extensions["group_store"]
|
|
group = group_store.create(
|
|
org_id="org-default", creator_id="admin", title="Source protected group"
|
|
)
|
|
group_store.update(
|
|
group["id"],
|
|
input={
|
|
"product": "CRM",
|
|
"segment": "SME",
|
|
"description": "safe summary",
|
|
"channel": "social",
|
|
"language": "th",
|
|
"files": ["internal.txt"],
|
|
"file_text": "provider-key=/private/path/source-secret",
|
|
},
|
|
)
|
|
admin = user_store.create_user(
|
|
org_id="org-default",
|
|
username="source-protected-admin",
|
|
password="source-protected-password",
|
|
name="Source Protected Admin",
|
|
role="admin",
|
|
must_setup=False,
|
|
)
|
|
token = login(admin["username"], "source-protected-password")["token"]
|
|
|
|
response = client.get(
|
|
f"/api/groups/{group['id']}", headers=_headers(token)
|
|
)
|
|
|
|
assert response.status_code == 200, response.get_json()
|
|
source = response.get_json()["group"]["input"]
|
|
assert source["product"] == "CRM"
|
|
assert "files" not in source
|
|
assert "file_text" not in source
|
|
assert "source-secret" not in response.get_data(as_text=True)
|
|
|
|
|
|
def test_super_admin_receives_full_group_view(client, user_store, login):
|
|
super_token = _setup_admin(user_store, login)
|
|
gid, analyzed = _ready_group(client, super_token)
|
|
|
|
assert analyzed["group"]["personas"][0]["pains"]
|
|
assert client.get(
|
|
f"/api/groups/{gid}/personas", headers=_headers(super_token)
|
|
).get_json()["personas"][0]["tolerance"]
|
|
|
|
|
|
def test_trainee_receives_revealable_persona_only(client, user_store, login):
|
|
super_token = _setup_admin(user_store, login)
|
|
gid, _ = _ready_group(client, super_token)
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username="trainee-redaction",
|
|
password="trainee-redaction-password",
|
|
name="Trainee",
|
|
role="user",
|
|
must_setup=False,
|
|
)
|
|
trainee_token = login(user["username"], "trainee-redaction-password")["token"]
|
|
|
|
persona = client.get(
|
|
f"/api/groups/{gid}/personas", headers=_headers(trainee_token)
|
|
).get_json()["personas"][0]
|
|
|
|
assert "name" in persona
|
|
assert "profession" in persona
|
|
assert "pains" not in persona
|
|
assert "income" not in persona
|
|
assert "personality" not in persona
|
|
assert "tolerance" not in persona
|
|
|
|
|
|
def test_trainee_group_view_hides_uploaded_source_data(
|
|
client, user_store, login
|
|
):
|
|
super_token = _setup_admin(user_store, login)
|
|
gid, _ = _ready_group(client, super_token)
|
|
group_store = client.application.extensions["group_store"]
|
|
group = group_store.get(gid)
|
|
group_store.update(
|
|
gid,
|
|
input={
|
|
"product": "CRM",
|
|
"segment": "SME",
|
|
"description": "safe product summary",
|
|
"files": ["internal-brief.txt"],
|
|
"file_text": "CONFIDENT INTERNAL SOURCE CONTENT",
|
|
},
|
|
)
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username="trainee-source-redaction",
|
|
password="trainee-source-redaction-password",
|
|
name="Trainee",
|
|
role="user",
|
|
must_setup=False,
|
|
)
|
|
trainee_token = login(user["username"], "trainee-source-redaction-password")["token"]
|
|
|
|
response = client.get(
|
|
f"/api/groups/{gid}", headers=_headers(trainee_token)
|
|
)
|
|
|
|
assert response.status_code == 200, response.get_json()
|
|
group_view = response.get_json()["group"]
|
|
assert group_view["input"]["product"] == "CRM"
|
|
assert "files" not in group_view["input"]
|
|
assert "file_text" not in group_view["input"]
|
|
|
|
|
|
def test_user_group_index_is_owner_scoped_and_revealable_only(tmp_path):
|
|
store = GroupStore(tmp_path)
|
|
private = store.create(
|
|
org_id="org-default", creator_id="owner-1", title="Private group"
|
|
)
|
|
store.update(
|
|
private["id"],
|
|
status="ready",
|
|
owner_user_id="owner-1",
|
|
input={"product": "CRM", "file_text": "private source"},
|
|
sales_kit={"productName": "CRM", "secret": "must-not-leak"},
|
|
report={"internal": "must-not-leak"},
|
|
personas=[
|
|
{
|
|
"id": "private-persona",
|
|
"name": "Private Persona",
|
|
"tier": "B",
|
|
"pains": [{"description": "hidden pain"}],
|
|
"future_hidden_signal": "must-not-leak",
|
|
}
|
|
],
|
|
)
|
|
shared = store.create(
|
|
org_id="org-default", creator_id="admin-1", title="Shared group"
|
|
)
|
|
store.update(
|
|
shared["id"],
|
|
status="ready",
|
|
input={"product": "Shared CRM"},
|
|
sales_kit={"productName": "Test product"},
|
|
report={"summary": "Test report"},
|
|
personas=[{"id": "shared-persona", "name": "Shared Persona", "tier": "A"}],
|
|
)
|
|
|
|
other_view = store.list_visible_to(
|
|
role="user", org_id="org-default", user_id="other-user"
|
|
)
|
|
assert {group["id"] for group in other_view} == {shared["id"]}
|
|
|
|
owner_view = store.list_visible_to(
|
|
role="user", org_id="org-default", user_id="owner-1"
|
|
)
|
|
private_view = next(group for group in owner_view if group["id"] == private["id"])
|
|
assert private_view["sales_kit"] is None
|
|
assert private_view["report"] is None
|
|
assert private_view["input"] == {"product": "CRM"}
|
|
assert private_view["personas"] == [
|
|
{
|
|
"id": "private-persona",
|
|
"name": "Private Persona",
|
|
"tier": "B",
|
|
"initiation_mode": "customer",
|
|
"channel": "facebook",
|
|
"profession": "",
|
|
"age_group": "",
|
|
"location": "",
|
|
"product_context": "",
|
|
}
|
|
]
|
|
|
|
|
|
def test_group_index_marks_user_owned_products_without_exposing_owner_id(
|
|
client, user_store, login
|
|
):
|
|
login_value = "owned-product-" + "index-password"
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username="owned-product-index-user",
|
|
password=login_value,
|
|
name="Owned Product User",
|
|
role="user",
|
|
must_setup=False,
|
|
)
|
|
token = login(user["username"], login_value)["token"]
|
|
groups = client.application.extensions["group_store"]
|
|
private = groups.create(
|
|
org_id="org-default",
|
|
creator_id=user["id"],
|
|
owner_user_id=user["id"],
|
|
visibility="private",
|
|
title="Private product",
|
|
)
|
|
groups.update(
|
|
private["id"],
|
|
status="ready",
|
|
input={"product": "Private CRM"},
|
|
sales_kit={"productName": "Test product"},
|
|
report={"summary": "Test report"},
|
|
personas=[{"id": "private-persona", "name": "Private Persona"}],
|
|
)
|
|
failed = groups.create(
|
|
org_id="org-default",
|
|
creator_id=user["id"],
|
|
owner_user_id=user["id"],
|
|
visibility="private",
|
|
title="Failed product",
|
|
)
|
|
groups.update(
|
|
failed["id"],
|
|
status="failed",
|
|
error="analysis_failed",
|
|
input={"product": "Failed CRM"},
|
|
)
|
|
shared = groups.create(
|
|
org_id="org-default",
|
|
creator_id="admin",
|
|
title="Shared product",
|
|
)
|
|
groups.update(
|
|
shared["id"],
|
|
status="ready",
|
|
input={"product": "Shared POS"},
|
|
sales_kit={"productName": "Test product"},
|
|
report={"summary": "Test report"},
|
|
personas=[{"id": "shared-persona", "name": "Shared Persona"}],
|
|
)
|
|
|
|
response = client.get("/api/groups", headers=_headers(token))
|
|
|
|
assert response.status_code == 200, response.get_json()
|
|
rows = {row["id"]: row for row in response.get_json()["groups"]}
|
|
assert rows[private["id"]]["is_owned"] is True
|
|
assert rows[failed["id"]]["is_owned"] is True
|
|
assert rows[failed["id"]]["status"] == "failed"
|
|
assert rows[shared["id"]]["is_owned"] is False
|
|
assert "owner_user_id" not in rows[private["id"]]
|
|
|
|
private_response = client.get(
|
|
f"/api/groups/{private['id']}", headers=_headers(token)
|
|
)
|
|
assert private_response.status_code == 200, private_response.get_json()
|
|
private_payload = private_response.get_json()["group"]
|
|
assert private_payload["is_owned"] is True
|
|
assert "owner_user_id" not in private_payload
|
|
|
|
|
|
@pytest.mark.parametrize("status", ["draft", "failed"])
|
|
def test_owner_can_open_non_ready_private_product_for_retry(
|
|
client, user_store, login, status
|
|
):
|
|
password = "retry-product-owner-password"
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username=f"retry-product-owner-{status}",
|
|
password=password,
|
|
name="Retry Product Owner",
|
|
role="user",
|
|
must_setup=False,
|
|
)
|
|
token = login(user["username"], password)["token"]
|
|
groups = client.application.extensions["group_store"]
|
|
product = groups.create(
|
|
org_id="org-default",
|
|
creator_id=user["id"],
|
|
owner_user_id=user["id"],
|
|
visibility="private",
|
|
title=f"{status.title()} product",
|
|
)
|
|
groups.update(
|
|
product["id"],
|
|
status=status,
|
|
error="analysis_failed" if status == "failed" else None,
|
|
input={"product": "Retry CRM"},
|
|
personas=[{"id": "stale-retry-persona", "name": "Stale Persona", "opener": "secret"}],
|
|
)
|
|
|
|
response = client.get(f"/api/groups/{product['id']}", headers=_headers(token))
|
|
|
|
assert response.status_code == 200, response.get_json()
|
|
payload = response.get_json()["group"]
|
|
assert payload["status"] == status
|
|
assert payload["is_owned"] is True
|
|
assert payload["personas"] == []
|
|
assert "owner_user_id" not in payload
|
|
|
|
|
|
def test_trainee_cannot_analyze(client, user_store, login):
|
|
super_token = _setup_admin(user_store, login)
|
|
gid, _ = _ready_group(client, super_token)
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username="trainee-no-analyze",
|
|
password="trainee-no-analyze-password",
|
|
name="Trainee",
|
|
role="user",
|
|
must_setup=False,
|
|
)
|
|
token = login(user["username"], "trainee-no-analyze-password")["token"]
|
|
|
|
response = client.post(f"/api/groups/{gid}/analyze", headers=_headers(token))
|
|
|
|
assert response.status_code == 403, response.get_json()
|
|
|
|
|
|
def test_trainee_variant_response_is_revealable_only(client, user_store, login):
|
|
super_token = _setup_admin(user_store, login)
|
|
gid, analyzed = _ready_group(client, super_token)
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username="trainee-variant",
|
|
password="trainee-variant-password",
|
|
name="Trainee",
|
|
role="user",
|
|
must_setup=False,
|
|
)
|
|
trainee_token = login(user["username"], "trainee-variant-password")["token"]
|
|
source_id = analyzed["personas"][0]["id"]
|
|
|
|
response = client.post(
|
|
f"/api/groups/{gid}/personas/{source_id}/variant",
|
|
headers=_headers(trainee_token),
|
|
)
|
|
|
|
assert response.status_code == 201, response.get_json()
|
|
persona = response.get_json()["persona"]
|
|
assert "name" in persona
|
|
assert "pains" not in persona
|
|
assert "income" not in persona
|
|
assert "tolerance" not in persona
|
|
|
|
|
|
def test_trainee_variant_is_stored_in_owner_private_group(
|
|
client, user_store, login
|
|
):
|
|
super_token = _setup_admin(user_store, login)
|
|
gid, analyzed = _ready_group(client, super_token)
|
|
user = user_store.create_user(
|
|
org_id="org-default",
|
|
username="private-variant-owner",
|
|
password="private-variant-owner-password",
|
|
name="Private Variant Owner",
|
|
role="user",
|
|
must_setup=False,
|
|
)
|
|
trainee_token = login(user["username"], "private-variant-owner-password")["token"]
|
|
headers = _headers(trainee_token)
|
|
source_id = analyzed["personas"][0]["id"]
|
|
|
|
response = client.post(
|
|
f"/api/groups/{gid}/personas/{source_id}/variant",
|
|
headers=headers,
|
|
)
|
|
|
|
assert response.status_code == 201, response.get_json()
|
|
payload = response.get_json()
|
|
variant = payload["persona"]
|
|
private_gid = payload["group_id"]
|
|
assert private_gid != gid
|
|
source_group = client.application.extensions["group_store"].get(gid)
|
|
private_group = client.application.extensions["group_store"].get(private_gid)
|
|
assert private_group["sales_kit"] == source_group["sales_kit"]
|
|
|
|
shared_personas = client.get(
|
|
f"/api/groups/{gid}/personas", headers=headers
|
|
).get_json()["personas"]
|
|
assert variant["id"] not in {p["id"] for p in shared_personas}
|
|
|
|
private_response = client.get(
|
|
f"/api/groups/{private_gid}/personas", headers=headers
|
|
)
|
|
assert private_response.status_code == 200, private_response.get_json()
|
|
private_personas = private_response.get_json()["personas"]
|
|
assert [p["id"] for p in private_personas] == [variant["id"]]
|
|
|
|
other_user = user_store.create_user(
|
|
org_id="org-default",
|
|
username="private-variant-other-user",
|
|
password="private-variant-other-password",
|
|
name="Other User",
|
|
role="user",
|
|
must_setup=False,
|
|
)
|
|
other_token = login(
|
|
other_user["username"], "private-variant-other-password"
|
|
)["token"]
|
|
other_headers = _headers(other_token)
|
|
assert client.get(
|
|
f"/api/groups/{private_gid}/personas", headers=other_headers
|
|
).status_code == 403
|
|
listed_ids = {
|
|
group["id"]
|
|
for group in client.get("/api/groups", headers=other_headers).get_json()["groups"]
|
|
}
|
|
assert private_gid not in listed_ids
|