feat: demo SaaS + training flow security hardening (8/8 review gate passed)
- 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.
This commit is contained in:
13
README.md
13
README.md
@@ -11,7 +11,7 @@ Built on patterns from the **CrowdSight / MiroFish** swarm engine and clean-room
|
||||
|
||||
## Features
|
||||
|
||||
- **Login + roles** (no self-registration): `super_admin` / `admin` / `user`.
|
||||
- **Login + roles** (no self-registration): `super_admin` / `admin` / `user` / `demo`.
|
||||
- Admin builds & edits **persona groups** (product + 15 personas), hand-edits personas, sees analytics.
|
||||
- User (trainee) **can't create** — only selects a group and practices; sees own results.
|
||||
- **Input** via form (product / segment / description) **and/or file upload** (.pdf/.md/.txt).
|
||||
@@ -34,6 +34,17 @@ Built on patterns from the **CrowdSight / MiroFish** swarm engine and clean-room
|
||||
- **Admin analytics**: close rate, avg score, hardest personas.
|
||||
- **EN + TH** UI.
|
||||
|
||||
### Demo SaaS
|
||||
|
||||
- Roles include `super_admin`, `admin`, `user`, and `demo`.
|
||||
- `demo` accounts are provisioned only by `super_admin` through `POST /api/admin/demo-accounts`,
|
||||
isolated in the dedicated `DEMO_ORG_ID` tenant, and can use only demo-only `ready` groups. Demo
|
||||
users cannot create groups/personas or change group visibility.
|
||||
- The first successful demo login starts exactly one **30-day UTC trial**. A `super_admin` can
|
||||
revoke a demo account; the one-time temporary password cannot be retrieved later.
|
||||
- Demo/SES/webhook environment variables are documented in `backend/.env.example`; SES and webhook
|
||||
delivery are opt-in and require deployment-specific configuration.
|
||||
|
||||
---
|
||||
|
||||
## Quick start
|
||||
|
||||
@@ -22,6 +22,28 @@ DATA_DIR=./data
|
||||
# OAUTH_FACEBOOK_APP_SECRET=
|
||||
# OAUTH_DEFAULT_ORG=org-public
|
||||
|
||||
# Demo accounts — created only by a super_admin, isolated in this tenant, and
|
||||
# granted a fixed 30-day UTC trial starting on first successful login.
|
||||
DEMO_ORG_ID=org-demo
|
||||
DEMO_ORG_NAME=Sales Trainer Demo
|
||||
DEMO_ORG_SEATS=10000
|
||||
|
||||
# Optional one-time demo credential delivery. Leave blank to skip delivery in
|
||||
# local/test environments. SES credentials use the normal AWS credential chain;
|
||||
# never put access keys or temporary passwords in this file.
|
||||
# AWS_REGION=ap-southeast-1
|
||||
# SES_FROM_EMAIL=verified-sender@example.com
|
||||
# SES_REPLY_TO=reply@example.com
|
||||
|
||||
# Optional authenticated demo-created webhook. The URL must be HTTPS; the
|
||||
# client rejects HTTP/cross-origin redirects. The temporary password is sent
|
||||
# only in the one-time request body and is not persisted or audit logged.
|
||||
# DEMO_WEBHOOK_URL=https://ops.example.com/sales-trainer/demo-created
|
||||
# DEMO_WEBHOOK_SECRET=replace_with_a_random_webhook_secret
|
||||
DEMO_WEBHOOK_TIMEOUT_SECONDS=5
|
||||
DEMO_WEBHOOK_MAX_RETRIES=3
|
||||
DEMO_WEBHOOK_RETRY_BACKOFF_SECONDS=0.25
|
||||
|
||||
# App
|
||||
FLASK_HOST=0.0.0.0
|
||||
FLASK_PORT=5001
|
||||
|
||||
@@ -5,6 +5,7 @@ from flask import Blueprint, jsonify, request
|
||||
|
||||
from ..auth.users import AuthError
|
||||
from ..config import Config
|
||||
|
||||
from .helpers import (
|
||||
ApiError,
|
||||
current_user,
|
||||
@@ -16,6 +17,8 @@ from .helpers import (
|
||||
|
||||
admin_bp = Blueprint("admin", __name__)
|
||||
|
||||
PUBLIC_ORG_FIELDS = ("id", "name", "plan", "seats", "active", "created_at")
|
||||
|
||||
|
||||
def _store():
|
||||
from flask import current_app
|
||||
@@ -23,6 +26,19 @@ def _store():
|
||||
return current_app.extensions["user_store"]
|
||||
|
||||
|
||||
def _delivery():
|
||||
from flask import current_app
|
||||
|
||||
return current_app.extensions["demo_delivery"]
|
||||
|
||||
|
||||
def _public_org(org: dict | None) -> dict | None:
|
||||
"""Serialize an organization through an explicit admin response allowlist."""
|
||||
if not isinstance(org, dict):
|
||||
return None
|
||||
return {field: org[field] for field in PUBLIC_ORG_FIELDS if field in org}
|
||||
|
||||
|
||||
def _log_audit(action: str, subject: str, *, detail: dict | None = None) -> None:
|
||||
"""Append a line to the audit log (sensitive platform actions)."""
|
||||
import json
|
||||
@@ -46,6 +62,101 @@ def _log_audit(action: str, subject: str, *, detail: dict | None = None) -> None
|
||||
pass
|
||||
|
||||
|
||||
@admin_bp.post("/demo-accounts")
|
||||
@require_auth
|
||||
@require_roles("super_admin")
|
||||
def create_demo_account():
|
||||
"""Create a dedicated-tenant demo account and deliver its password once."""
|
||||
data = request_json_object()
|
||||
if set(data) != {"email"}:
|
||||
raise ApiError("email is the only accepted field", 400)
|
||||
try:
|
||||
email = _store().validate_email(data.get("email"), allow_empty=False)
|
||||
except AuthError:
|
||||
raise ApiError("a valid email is required", 400)
|
||||
if email is None: # defensive: required validation above
|
||||
raise ApiError("a valid email is required", 400)
|
||||
store = _store()
|
||||
try:
|
||||
if store.email_exists(email):
|
||||
raise ApiError("a demo account with this email already exists", 409)
|
||||
org = store.ensure_demo_org()
|
||||
delivery = _delivery()
|
||||
username = delivery.generate_username(store.get_user_or_none)
|
||||
temporary_password = delivery.generate_password()
|
||||
user = store.create_demo_user(
|
||||
email=email,
|
||||
username=username,
|
||||
password=temporary_password,
|
||||
)
|
||||
except ApiError:
|
||||
raise
|
||||
except AuthError as exc:
|
||||
if "email" in str(exc).lower() and "exist" in str(exc).lower():
|
||||
raise ApiError("a demo account with this email already exists", 409)
|
||||
raise internal_error("demo account could not be created", exc, 400)
|
||||
except Exception as exc:
|
||||
raise internal_error("demo account could not be created", exc, 500)
|
||||
|
||||
# Delivery is deliberately best-effort. The account remains usable through
|
||||
# the one-time API response even when SES/webhook configuration is absent.
|
||||
try:
|
||||
delivery_status = delivery.deliver(
|
||||
email=email,
|
||||
username=user["username"],
|
||||
temporary_password=temporary_password,
|
||||
trial_expires_at=user.get("trial_expires_at"),
|
||||
)
|
||||
except Exception as exc:
|
||||
from flask import current_app
|
||||
|
||||
current_app.logger.error(
|
||||
"demo credential delivery failed (error_type=%s)", type(exc).__name__
|
||||
)
|
||||
delivery_status = {
|
||||
"event_id": None,
|
||||
"email": {"status": "failed", "reason": "delivery_error", "attempts": 0},
|
||||
"webhook": {"status": "failed", "reason": "delivery_error", "attempts": 0},
|
||||
}
|
||||
_log_audit(
|
||||
"demo_account.create",
|
||||
user["id"],
|
||||
detail={
|
||||
"email": email,
|
||||
"org_id": org["id"],
|
||||
"delivery": delivery_status,
|
||||
},
|
||||
)
|
||||
return jsonify({
|
||||
"user": store.public_user(user),
|
||||
"username": user["username"],
|
||||
"temporary_password": temporary_password,
|
||||
"delivery": delivery_status,
|
||||
"warning": "The temporary password is shown once and cannot be retrieved later.",
|
||||
}), 201
|
||||
|
||||
|
||||
@admin_bp.post("/demo-accounts/<username>/revoke")
|
||||
@admin_bp.delete("/demo-accounts/<username>")
|
||||
@require_auth
|
||||
@require_roles("super_admin")
|
||||
def revoke_demo_account(username: str):
|
||||
"""Deactivate a demo account and revoke all tokens through auth_version."""
|
||||
store = _store()
|
||||
try:
|
||||
target = store.get_user_or_none(username)
|
||||
except AuthError:
|
||||
raise ApiError("demo account not found", 404)
|
||||
if not target or target.get("role") != "demo":
|
||||
raise ApiError("demo account not found", 404)
|
||||
try:
|
||||
updated = store.revoke_demo(target["username"])
|
||||
except AuthError:
|
||||
raise ApiError("demo account not found", 404)
|
||||
_log_audit("demo_account.revoke", updated["id"], detail={"org_id": updated.get("org_id")})
|
||||
return jsonify({"user": store.public_user(updated), "revoked": True})
|
||||
|
||||
|
||||
@admin_bp.get("/orgs")
|
||||
@require_auth
|
||||
@require_roles("admin")
|
||||
@@ -53,11 +164,12 @@ def list_orgs():
|
||||
"""Platform view: all organizations (super_admin). Admin sees only their own."""
|
||||
actor = current_user()
|
||||
if actor.get("role") == "super_admin":
|
||||
orgs = list(_store().orgs.all())
|
||||
orgs = [_public_org(org) for org in _store().orgs.all()]
|
||||
return jsonify({"orgs": orgs})
|
||||
# plain admin: only their own org
|
||||
org = _store().orgs.get_or_none(actor.get("org_id"))
|
||||
return jsonify({"orgs": [org] if org else []})
|
||||
public_org = _public_org(org)
|
||||
return jsonify({"orgs": [public_org] if public_org else []})
|
||||
|
||||
|
||||
@admin_bp.patch("/orgs/<org_id>")
|
||||
@@ -95,7 +207,7 @@ def update_org(org_id: str):
|
||||
org_store.update(org_id, **fields)
|
||||
updated = org_store.get(org_id)
|
||||
_log_audit("org.update", org_id, detail=fields)
|
||||
return jsonify({"org": updated})
|
||||
return jsonify({"org": _public_org(updated)})
|
||||
|
||||
|
||||
@admin_bp.post("/users")
|
||||
@@ -151,6 +263,8 @@ def create_user():
|
||||
raise internal_error("user could not be created", exc, 400)
|
||||
if role not in Config.ROLES:
|
||||
raise ApiError(f"invalid role: {role}")
|
||||
if role == "demo":
|
||||
raise ApiError("use the super_admin demo account endpoint", 403)
|
||||
# Super-admin accounts are bootstrap/platform identities. A super_admin may
|
||||
# provision another super_admin (trust-based promotion); any other actor may
|
||||
# not. Admin grants are super_admin-only (kept below).
|
||||
@@ -180,6 +294,8 @@ def create_user():
|
||||
name=name,
|
||||
role=role,
|
||||
email=normalized_email,
|
||||
must_setup=True,
|
||||
accepted_terms=False,
|
||||
)
|
||||
except AuthError as exc:
|
||||
if new_org:
|
||||
@@ -228,6 +344,8 @@ def update_user(username: str):
|
||||
or target.get("role") == "super_admin"
|
||||
):
|
||||
raise ApiError("user not found", 404)
|
||||
if target.get("role") == "demo":
|
||||
raise ApiError("manage demo accounts through the dedicated demo endpoint", 403)
|
||||
|
||||
# Only a super_admin may manage super_admin accounts (promotion or edits).
|
||||
# A regular admin already can't reach here for a super_admin target (404 above).
|
||||
|
||||
@@ -9,6 +9,7 @@ from flask import Blueprint, g, jsonify, request
|
||||
|
||||
from ..auth.users import AuthError, normalize_identifier
|
||||
from ..config import Config
|
||||
from ..services.groups import is_ready_group, is_valid_owner_visibility, validated_personas
|
||||
from ..storage.store import StoreError
|
||||
from .helpers import ApiError, current_user, is_valid_tenant_id, require_auth, require_roles
|
||||
|
||||
@@ -25,6 +26,88 @@ def _stores():
|
||||
}
|
||||
|
||||
|
||||
def _is_shared_group(group: object) -> bool:
|
||||
"""Accept only structurally valid tenant-shared group records."""
|
||||
return (
|
||||
isinstance(group, dict)
|
||||
and is_valid_tenant_id(group.get("id"))
|
||||
and is_valid_tenant_id(group.get("org_id"))
|
||||
and "owner_user_id" not in group
|
||||
and is_valid_owner_visibility(group)
|
||||
and group.get("visibility") in {"public", "hidden"}
|
||||
and is_ready_group(group)
|
||||
)
|
||||
|
||||
|
||||
def _shared_ready_contexts(groups: object) -> dict[str, tuple[str, set[str]]]:
|
||||
"""Map ready shared groups to their tenant and current persona IDs."""
|
||||
contexts: dict[str, tuple[str, set[str]]] = {}
|
||||
if not isinstance(groups, list):
|
||||
return contexts
|
||||
for group in groups:
|
||||
if not _is_shared_group(group):
|
||||
continue
|
||||
gid = group.get("id")
|
||||
org_id = group.get("org_id")
|
||||
if not isinstance(gid, str) or not isinstance(org_id, str):
|
||||
continue
|
||||
persona_ids = {
|
||||
persona["id"]
|
||||
for persona in validated_personas(group.get("personas"))
|
||||
if isinstance(persona.get("id"), str)
|
||||
}
|
||||
if persona_ids:
|
||||
contexts[gid] = (org_id, persona_ids)
|
||||
return contexts
|
||||
|
||||
|
||||
def _canonical_persona_ids(value: object) -> set[str]:
|
||||
ids: set[str] = set()
|
||||
if not isinstance(value, list):
|
||||
return ids
|
||||
for persona in value:
|
||||
if not isinstance(persona, dict):
|
||||
continue
|
||||
pid = persona.get("id")
|
||||
if isinstance(pid, str) and pid.strip():
|
||||
ids.add(pid.strip())
|
||||
return ids
|
||||
|
||||
|
||||
def _is_legacy_trainee_mode(value: object) -> bool:
|
||||
"""Accept missing mode for legacy rows, but reject malformed falsey values."""
|
||||
return value is None or value == "trainee"
|
||||
|
||||
|
||||
def _session_matches_context(
|
||||
session: object,
|
||||
contexts: dict[str, tuple[str, set[str]]],
|
||||
*,
|
||||
org_id: str | None = None,
|
||||
) -> bool:
|
||||
if not isinstance(session, dict):
|
||||
return False
|
||||
gid = session.get("group_id")
|
||||
pid = session.get("persona_id")
|
||||
session_org = session.get("org_id")
|
||||
if not (
|
||||
is_valid_tenant_id(gid)
|
||||
and is_valid_tenant_id(pid)
|
||||
and is_valid_tenant_id(session_org)
|
||||
):
|
||||
return False
|
||||
context = contexts.get(gid)
|
||||
if context is None:
|
||||
return False
|
||||
group_org, persona_ids = context
|
||||
return (
|
||||
is_valid_tenant_id(group_org)
|
||||
and session_org == group_org
|
||||
and (org_id is None or group_org == org_id)
|
||||
and pid in persona_ids
|
||||
)
|
||||
|
||||
|
||||
def _log_audit(action: str, subject: str, *, detail: dict | None = None) -> None:
|
||||
import json
|
||||
import time
|
||||
@@ -50,8 +133,19 @@ def _log_audit(action: str, subject: str, *, detail: dict | None = None) -> None
|
||||
|
||||
|
||||
def _csv_cell(value: object, *, max_chars: int | None = None) -> str:
|
||||
"""Keep exported cells from being interpreted as spreadsheet formulas."""
|
||||
text = "" if value is None else str(value)
|
||||
"""Export bounded scalar values without serializing persisted structures."""
|
||||
if value is None:
|
||||
text = ""
|
||||
elif isinstance(value, str):
|
||||
text = value
|
||||
elif isinstance(value, bool):
|
||||
text = ""
|
||||
elif isinstance(value, int):
|
||||
text = str(value)
|
||||
elif isinstance(value, float) and math.isfinite(value):
|
||||
text = str(value)
|
||||
else:
|
||||
text = ""
|
||||
if max_chars is not None:
|
||||
text = text[:max_chars]
|
||||
probe = text
|
||||
@@ -406,22 +500,52 @@ def analytics():
|
||||
return False
|
||||
return True
|
||||
|
||||
max_scan_records = Config.ANALYTICS_EXPORT_MAX_SCAN_RECORDS
|
||||
if (
|
||||
isinstance(max_scan_records, bool)
|
||||
or not isinstance(max_scan_records, int)
|
||||
or max_scan_records <= 0
|
||||
):
|
||||
raise ApiError("analytics is unavailable", 503)
|
||||
|
||||
scanned_records = 0
|
||||
|
||||
def _bounded_records(collection: object) -> list[dict]:
|
||||
nonlocal scanned_records
|
||||
raw_iter = getattr(collection, "iter_all", None)
|
||||
if not callable(raw_iter):
|
||||
raise StoreError("analytics store lacks bounded iteration")
|
||||
record_iter = cast(Callable[[], Iterator[dict]], raw_iter)
|
||||
records: list[dict] = []
|
||||
for record in record_iter():
|
||||
scanned_records += 1
|
||||
if scanned_records > max_scan_records:
|
||||
raise ApiError("analytics scan limit exceeded", 413)
|
||||
if isinstance(record, dict):
|
||||
records.append(record)
|
||||
return records
|
||||
|
||||
try:
|
||||
groups = _bounded_records(s["groups"].groups)
|
||||
users = _bounded_records(s["users"].users)
|
||||
all_sessions = _bounded_records(s["sessions"].sessions)
|
||||
except (OSError, StoreError, TypeError, ValueError) as exc:
|
||||
raise ApiError("analytics unavailable", 503) from exc
|
||||
|
||||
shared_contexts = _shared_ready_contexts(groups)
|
||||
if actor.get("role") == "super_admin":
|
||||
users = [
|
||||
user
|
||||
for user in s["users"].list_users()
|
||||
if isinstance(user, dict)
|
||||
]
|
||||
trainee_users = [user for user in users if user.get("role") == "user"]
|
||||
trainee_count = len(trainee_users)
|
||||
user_ids = {
|
||||
user["id"] for user in trainee_users if isinstance(user.get("id"), str)
|
||||
user_org_by_id = {
|
||||
user["id"]: user.get("org_id")
|
||||
for user in trainee_users
|
||||
if isinstance(user.get("id"), str)
|
||||
}
|
||||
sessions = [
|
||||
x for x in s["sessions"].sessions.all()
|
||||
if isinstance(x, dict)
|
||||
and x.get("user_id") in user_ids
|
||||
and (x.get("mode") or "trainee") == "trainee"
|
||||
x for x in all_sessions
|
||||
if user_org_by_id.get(x.get("user_id")) == x.get("org_id")
|
||||
and _is_legacy_trainee_mode(x.get("mode"))
|
||||
and _session_matches_context(x, shared_contexts)
|
||||
and _is_finished_outcome(x)
|
||||
and _in_window(x)
|
||||
]
|
||||
@@ -429,31 +553,23 @@ def analytics():
|
||||
org_id = actor.get("org_id")
|
||||
if not is_valid_tenant_id(org_id):
|
||||
raise ApiError("permission denied", 403)
|
||||
users = [
|
||||
user
|
||||
for user in s["users"].list_users(org_id=org_id)
|
||||
if isinstance(user, dict)
|
||||
]
|
||||
users = [user for user in users if user.get("org_id") == org_id]
|
||||
trainee_users = [user for user in users if user.get("role") == "user"]
|
||||
trainee_count = len(trainee_users)
|
||||
user_ids = {
|
||||
user["id"] for user in trainee_users if isinstance(user.get("id"), str)
|
||||
}
|
||||
group_ids = {
|
||||
group.get("id")
|
||||
for group in s["groups"].groups.all()
|
||||
if isinstance(group, dict)
|
||||
and isinstance(group.get("id"), str)
|
||||
and group.get("org_id") == org_id
|
||||
and "owner_user_id" not in group
|
||||
gid for gid, (group_org, _persona_ids) in shared_contexts.items()
|
||||
if group_org == org_id
|
||||
}
|
||||
sessions = [
|
||||
x for x in s["sessions"].sessions.all()
|
||||
if isinstance(x, dict)
|
||||
and x.get("user_id") in user_ids
|
||||
x for x in all_sessions
|
||||
if x.get("user_id") in user_ids
|
||||
and x.get("org_id") == org_id
|
||||
and x.get("group_id") in group_ids
|
||||
and (x.get("mode") or "trainee") == "trainee"
|
||||
and _is_legacy_trainee_mode(x.get("mode"))
|
||||
and _session_matches_context(x, shared_contexts, org_id=org_id)
|
||||
and _is_finished_outcome(x)
|
||||
and _in_window(x)
|
||||
]
|
||||
@@ -477,7 +593,7 @@ def analytics():
|
||||
from ..services.trainee import analyze_team_weak_areas
|
||||
|
||||
persona_difficulty: dict[tuple[str, str], int] = {}
|
||||
for group_candidate in s["groups"].groups.all():
|
||||
for group_candidate in groups:
|
||||
if not isinstance(group_candidate, dict) or not isinstance(group_candidate.get("id"), str):
|
||||
continue
|
||||
gid = group_candidate["id"]
|
||||
@@ -662,21 +778,6 @@ def _export_csv_for_actor(actor: dict, *, audit: bool = True):
|
||||
if not is_super_admin and not is_valid_tenant_id(export_org):
|
||||
raise ApiError("permission denied", 403)
|
||||
s = _stores()
|
||||
users = {}
|
||||
try:
|
||||
user_store = current_app.extensions.get("user_store")
|
||||
if user_store and hasattr(user_store, "list_users"):
|
||||
for rec in user_store.list_users(org_id=export_org):
|
||||
if not isinstance(rec, dict):
|
||||
continue
|
||||
if rec.get("role") != "user":
|
||||
continue
|
||||
user_id = rec.get("id") or rec.get("username")
|
||||
if isinstance(user_id, str):
|
||||
users[user_id] = rec.get("username") or rec.get("id")
|
||||
except (OSError, StoreError, TypeError, ValueError) as exc:
|
||||
raise ApiError("analytics unavailable", 503) from exc
|
||||
|
||||
max_rows = Config.ANALYTICS_EXPORT_MAX_ROWS
|
||||
max_bytes = Config.ANALYTICS_EXPORT_MAX_BYTES
|
||||
max_scan_records = Config.ANALYTICS_EXPORT_MAX_SCAN_RECORDS
|
||||
@@ -692,21 +793,41 @@ def _export_csv_for_actor(actor: dict, *, audit: bool = True):
|
||||
or max_scan_records <= 0
|
||||
):
|
||||
raise ApiError("analytics export is unavailable", 503)
|
||||
scanned_records = 0
|
||||
users: dict[str, dict[str, object]] = {}
|
||||
sessions = []
|
||||
try:
|
||||
user_store = current_app.extensions.get("user_store")
|
||||
grp_store = s["groups"]
|
||||
sess_store = s["sessions"]
|
||||
user_collection = getattr(user_store, "users", None)
|
||||
group_collection = getattr(grp_store, "groups", grp_store)
|
||||
session_collection = getattr(sess_store, "sessions", sess_store)
|
||||
raw_user_iter = getattr(user_collection, "iter_all", None)
|
||||
raw_group_iter = getattr(group_collection, "iter_all", None)
|
||||
raw_session_iter = getattr(session_collection, "iter_all", None)
|
||||
if not callable(raw_group_iter) or not callable(raw_session_iter):
|
||||
if not callable(raw_user_iter) or not callable(raw_group_iter) or not callable(raw_session_iter):
|
||||
raise StoreError("analytics stores lack bounded iteration")
|
||||
user_iter = cast(Callable[[], Iterator[dict]], raw_user_iter)
|
||||
group_iter = cast(Callable[[], Iterator[dict]], raw_group_iter)
|
||||
session_iter = cast(Callable[[], Iterator[dict]], raw_session_iter)
|
||||
|
||||
scanned_records = 0
|
||||
group_ids: set[str] = set()
|
||||
for rec in user_iter():
|
||||
scanned_records += 1
|
||||
if scanned_records > max_scan_records:
|
||||
raise ApiError("analytics export scan limit exceeded", 413)
|
||||
if not isinstance(rec, dict) or rec.get("role") != "user":
|
||||
continue
|
||||
if export_org is not None and rec.get("org_id") != export_org:
|
||||
continue
|
||||
user_id = rec.get("id") or rec.get("username")
|
||||
if isinstance(user_id, str):
|
||||
users[user_id] = {
|
||||
"username": rec.get("username") or rec.get("id"),
|
||||
"org_id": rec.get("org_id"),
|
||||
}
|
||||
|
||||
shared_contexts: dict[str, tuple[str, set[str]]] = {}
|
||||
for group in group_iter():
|
||||
scanned_records += 1
|
||||
if scanned_records > max_scan_records:
|
||||
@@ -715,9 +836,11 @@ def _export_csv_for_actor(actor: dict, *, audit: bool = True):
|
||||
isinstance(group, dict)
|
||||
and isinstance(group.get("id"), str)
|
||||
and (export_org is None or group.get("org_id") == export_org)
|
||||
and (export_org is None or "owner_user_id" not in group)
|
||||
and _is_shared_group(group)
|
||||
):
|
||||
group_ids.add(group["id"])
|
||||
persona_ids = _canonical_persona_ids(group.get("personas"))
|
||||
if persona_ids:
|
||||
shared_contexts[group["id"]] = (group["org_id"], persona_ids)
|
||||
|
||||
for row in session_iter():
|
||||
scanned_records += 1
|
||||
@@ -725,11 +848,12 @@ def _export_csv_for_actor(actor: dict, *, audit: bool = True):
|
||||
raise ApiError("analytics export scan limit exceeded", 413)
|
||||
if not (
|
||||
isinstance(row, dict)
|
||||
and row.get("group_id") in group_ids
|
||||
and (row.get("mode") or "trainee") == "trainee"
|
||||
and _is_legacy_trainee_mode(row.get("mode"))
|
||||
and _session_matches_context(row, shared_contexts, org_id=export_org)
|
||||
and row.get("status") == "finished"
|
||||
and row.get("outcome") in {"won", "lost"}
|
||||
and row.get("user_id") in users
|
||||
and users[row["user_id"]].get("org_id") == row.get("org_id")
|
||||
and (
|
||||
export_org is None
|
||||
or row.get("org_id") == export_org
|
||||
@@ -767,7 +891,9 @@ def _export_csv_for_actor(actor: dict, *, audit: bool = True):
|
||||
for sess in sessions:
|
||||
w.writerow([
|
||||
_csv_cell(
|
||||
users.get(sess.get("user_id"), sess.get("user_id", "")),
|
||||
users.get(sess.get("user_id"), {}).get(
|
||||
"username", sess.get("user_id", "")
|
||||
),
|
||||
max_chars=Config.ANALYTICS_EXPORT_MAX_CELL_CHARS,
|
||||
),
|
||||
_csv_cell(
|
||||
|
||||
@@ -1,14 +1,19 @@
|
||||
"""Auth routes: login, current user, first-time admin setup, self-registration.
|
||||
|
||||
Self-registration creates a default-role (``user``) seat-checked account in a
|
||||
default org. When the platform has zero users, the first registered user is
|
||||
promoted to ``super_admin`` automatically (global bootstrap).
|
||||
default org. Privileged bootstrap is handled only by the application factory;
|
||||
public registration can never grant an administrative role.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from ..auth.users import AuthError, SetupAlreadyCompletedError, is_valid_tenant_id
|
||||
from ..auth.users import (
|
||||
AuthError,
|
||||
SetupAlreadyCompletedError,
|
||||
is_valid_tenant_id,
|
||||
utc_now,
|
||||
)
|
||||
from ..config import Config
|
||||
from .helpers import ApiError, current_user, internal_error, request_json_object, require_auth
|
||||
|
||||
@@ -30,7 +35,7 @@ def _ensure_register_org():
|
||||
"""
|
||||
store = _store()
|
||||
org_id = Config.OAUTH_DEFAULT_ORG or "org-default"
|
||||
if not is_valid_tenant_id(org_id):
|
||||
if not is_valid_tenant_id(org_id) or Config._is_placeholder(org_id):
|
||||
raise ApiError("registration is unavailable", 503)
|
||||
org = store.get_org_or_none(org_id)
|
||||
if org is None:
|
||||
@@ -70,13 +75,19 @@ def login():
|
||||
raise ApiError("too many attempts, try again later", 429)
|
||||
try:
|
||||
user = _store().verify(username, password)
|
||||
if user.get("role") == "demo":
|
||||
# The trial clock starts only after password verification succeeds.
|
||||
# start_demo_trial is record-locked so concurrent first logins share
|
||||
# one exact 30-day UTC window.
|
||||
user = _store().start_demo_trial(user.get("username") or user.get("id"))
|
||||
token = _store().issue_token(user)
|
||||
except AuthError:
|
||||
raise ApiError("invalid credentials", 401)
|
||||
public_user = _store().public_user(user)
|
||||
return jsonify({
|
||||
"token": token,
|
||||
"user": _store().public_user(user),
|
||||
"must_setup": user.get("must_setup") is True,
|
||||
"user": public_user,
|
||||
"must_setup": public_user["must_setup"],
|
||||
})
|
||||
|
||||
|
||||
@@ -84,9 +95,8 @@ def login():
|
||||
def register():
|
||||
"""Self-service registration: username + password + email, default role=user.
|
||||
|
||||
Seat-checked, lands in the registration default org. When the platform has
|
||||
zero users, the first registered user is auto-promoted to ``super_admin``
|
||||
(global bootstrap); otherwise the role stays ``user`` and is never super.
|
||||
Seat-checked and lands in the registration default org. The role is always
|
||||
``user``; deleting all users must never re-arm public super-admin creation.
|
||||
"""
|
||||
data = request_json_object(allow_empty=True)
|
||||
username = data.get("username")
|
||||
@@ -98,6 +108,8 @@ def register():
|
||||
raise ApiError("password is required", 400)
|
||||
if not isinstance(email, str) or not email.strip():
|
||||
raise ApiError("email is required", 400)
|
||||
if data.get("accepted_terms") is not True:
|
||||
raise ApiError("you must accept the Terms of Service and Privacy Policy to continue", 400)
|
||||
|
||||
from ..services.rate_limit import check as ratelimit
|
||||
|
||||
@@ -111,23 +123,19 @@ def register():
|
||||
store = _store()
|
||||
org_id = _ensure_register_org()
|
||||
|
||||
# First-created-user rule: an empty user store promotes the first account to
|
||||
# super_admin (global bootstrap). All subsequent accounts default to user.
|
||||
# The check-to-create must be atomic so two concurrent workers cannot both
|
||||
# see an empty store; the collection lock inside create_user serializes it,
|
||||
# so we set the role based on emptiness and rely on create_user's uniqueness.
|
||||
promote_first = len(store.users.all()) == 0
|
||||
|
||||
try:
|
||||
user = store.create_user(
|
||||
org_id=org_id,
|
||||
username=username,
|
||||
password=password,
|
||||
name=username,
|
||||
role="super_admin" if promote_first else "user",
|
||||
email=email,
|
||||
must_setup=False,
|
||||
)
|
||||
with store.users.collection_lock():
|
||||
user = store.create_user(
|
||||
org_id=org_id,
|
||||
username=username,
|
||||
password=password,
|
||||
name=username,
|
||||
role="user",
|
||||
email=email,
|
||||
must_setup=False,
|
||||
accepted_terms=True,
|
||||
_users_lock_held=True,
|
||||
)
|
||||
token = store.issue_token(user)
|
||||
except AuthError as exc:
|
||||
# Generic; never leak why (seats / uniqueness / inactive org / invalid input).
|
||||
@@ -140,10 +148,11 @@ def register():
|
||||
except (OSError, TypeError, ValueError, UnicodeError, OverflowError) as exc:
|
||||
raise internal_error("registration failed", exc, 503)
|
||||
|
||||
public_user = store.public_user(user)
|
||||
return jsonify({
|
||||
"token": token,
|
||||
"user": store.public_user(user),
|
||||
"must_setup": user.get("must_setup") is True,
|
||||
"user": public_user,
|
||||
"must_setup": public_user["must_setup"],
|
||||
}), 201
|
||||
|
||||
|
||||
@@ -169,7 +178,7 @@ def setup():
|
||||
requested_username = (requested_username_raw or "").strip().lower()
|
||||
if requested_username and requested_username != username:
|
||||
raise ApiError("permission denied", 403)
|
||||
if user.get("must_setup") is not True:
|
||||
if user.get("must_setup") is False and user.get("accepted_terms") is True:
|
||||
raise ApiError("setup already completed", 409)
|
||||
email = data.get("email")
|
||||
new_password = data.get("password")
|
||||
@@ -184,7 +193,7 @@ def setup():
|
||||
email,
|
||||
new_password,
|
||||
accepted_terms=data.get("accepted_terms"),
|
||||
accepted_terms_at=__import__("time").strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
server_generated_at=utc_now().isoformat(),
|
||||
)
|
||||
except SetupAlreadyCompletedError:
|
||||
raise ApiError("setup already completed", 409)
|
||||
|
||||
@@ -1,16 +1,28 @@
|
||||
"""Chat/session API: start a one-shot session, send messages, finish + debrief."""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections.abc import Collection
|
||||
import functools
|
||||
import math
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from ..config import Config
|
||||
from ..llm import LLMError
|
||||
from ..services.simulator import Simulator
|
||||
from ..services.groups import (
|
||||
group_visibility,
|
||||
is_canonical_private_owner,
|
||||
is_ready_group,
|
||||
is_valid_owner_visibility,
|
||||
)
|
||||
from ..services.simulator import Simulator, _safe_roleplay_internal
|
||||
from ..services.store import PERSONA_CHANNELS
|
||||
from ..storage.store import StoreNotFoundError
|
||||
from .helpers import (
|
||||
ApiError,
|
||||
current_user,
|
||||
internal_error,
|
||||
is_valid_tenant_id,
|
||||
request_json_object,
|
||||
require_auth,
|
||||
require_roles,
|
||||
@@ -18,6 +30,19 @@ from .helpers import (
|
||||
|
||||
chat_bp = Blueprint("chat", __name__)
|
||||
|
||||
# Only high-level fields already visible before/while training may appear in a
|
||||
# debrief. Latent persona context and the sales formula stay server-side.
|
||||
REVEALED_PERSONA_FIELDS = (
|
||||
"name",
|
||||
"tier",
|
||||
"initiation_mode",
|
||||
"channel",
|
||||
"profession",
|
||||
"age_group",
|
||||
"location",
|
||||
"product_context",
|
||||
)
|
||||
|
||||
|
||||
def _stores():
|
||||
from flask import current_app
|
||||
@@ -35,14 +60,13 @@ def _session_mutation(fn):
|
||||
def wrapped(gid: str, pid: str, *args, **kwargs):
|
||||
s = _stores()
|
||||
actor = current_user()
|
||||
mode = "trainee" # no preview mode; every role trains one-shot
|
||||
session = s["sessions"].active_for_scope(
|
||||
org_id=actor.get("org_id"), user_id=actor["id"],
|
||||
group_id=gid, persona_id=pid, mode=mode,
|
||||
)
|
||||
session = _active_session_for_actor(s, actor, gid=gid, pid=pid)
|
||||
if session:
|
||||
with s["sessions"].mutation_lock(session["id"]):
|
||||
return fn(gid, pid, *args, **kwargs)
|
||||
# Group deletion takes the group lock before cascading session locks.
|
||||
# Keep the same order here so revocation cannot deadlock with send/finish.
|
||||
with s["groups"].record_lock(gid):
|
||||
with s["sessions"].mutation_lock(session["id"]):
|
||||
return fn(gid, pid, *args, **kwargs)
|
||||
return fn(gid, pid, *args, **kwargs)
|
||||
return wrapped
|
||||
|
||||
@@ -91,6 +115,52 @@ def _scenario_config(scenario: str, persona: dict, locale: str = "th"):
|
||||
return cfg, init_mode
|
||||
|
||||
|
||||
def _public_scenario_meta(value: object) -> dict[str, str]:
|
||||
"""Return only scenario labels safe for the client surface."""
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
return {
|
||||
field: value[field].strip()[:200]
|
||||
for field in ("label", "init")
|
||||
if isinstance(value.get(field), str) and value[field].strip()
|
||||
}
|
||||
|
||||
|
||||
def _safe_scenario(value: object) -> str:
|
||||
return value if isinstance(value, str) and value in {"social", "f2f_call"} else "social"
|
||||
|
||||
|
||||
def _safe_locale(value: object) -> str:
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"en", "th"}:
|
||||
return normalized
|
||||
return "th"
|
||||
|
||||
|
||||
def _safe_enum(value: object, allowed: Collection[str]) -> str | None:
|
||||
if not isinstance(value, str):
|
||||
return None
|
||||
normalized = value.strip()
|
||||
return normalized if normalized in allowed else None
|
||||
|
||||
|
||||
def _safe_persona_meta(value: object) -> dict[str, str]:
|
||||
raw = value if isinstance(value, dict) else {}
|
||||
safe: dict[str, str] = {}
|
||||
for field, allowed in (
|
||||
("tier", {"A", "B", "C"}),
|
||||
("initiation_mode", {"customer", "seller"}),
|
||||
("channel", set(PERSONA_CHANNELS)),
|
||||
):
|
||||
normalized = _safe_enum(raw.get(field), allowed)
|
||||
if normalized is not None:
|
||||
safe[field] = normalized
|
||||
safe["scenario"] = _safe_scenario(raw.get("scenario"))
|
||||
safe["locale"] = _safe_locale(raw.get("locale"))
|
||||
return safe
|
||||
|
||||
|
||||
def _bounded_text(value: object, limit: int = 2000) -> str:
|
||||
return value.strip()[:limit] if isinstance(value, str) else ""
|
||||
|
||||
@@ -101,56 +171,131 @@ def _string_list(value: object, *, max_items: int = 20, item_limit: int = 1000)
|
||||
return [item.strip()[:item_limit] for item in value[:max_items] if isinstance(item, str) and item.strip()]
|
||||
|
||||
|
||||
def _safe_judge_debrief(verdict: object, outcome: str, persona: dict) -> dict:
|
||||
"""Keep provider-controlled judge output inside a closed response envelope."""
|
||||
raw = verdict if isinstance(verdict, dict) else {}
|
||||
def _bounded_int(
|
||||
value: object,
|
||||
*,
|
||||
default: int,
|
||||
minimum: int,
|
||||
maximum: int,
|
||||
) -> int:
|
||||
if isinstance(value, bool) or not isinstance(value, (str, int, float)):
|
||||
return default
|
||||
if isinstance(value, float) and not math.isfinite(value):
|
||||
return default
|
||||
try:
|
||||
score = max(0, min(100, int(raw.get("score", 0))))
|
||||
except (TypeError, ValueError):
|
||||
score = 0
|
||||
parsed = int(value)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return default
|
||||
return max(minimum, min(maximum, parsed))
|
||||
|
||||
|
||||
def _safe_revealed_persona(persona: dict) -> dict:
|
||||
"""Return only bounded scalar values from the public persona surface."""
|
||||
safe = {}
|
||||
for field in REVEALED_PERSONA_FIELDS:
|
||||
if field not in persona:
|
||||
continue
|
||||
value = persona[field]
|
||||
if isinstance(value, str) and value.strip():
|
||||
safe[field] = value.strip()[:500]
|
||||
return safe
|
||||
|
||||
|
||||
def _safe_judge_debrief(
|
||||
verdict: object,
|
||||
outcome: str,
|
||||
persona: dict,
|
||||
public_debrief: object | None = None,
|
||||
) -> dict:
|
||||
"""Return score plus public coaching, never hidden-judge prose.
|
||||
|
||||
The final judge sees latent persona data, so its narrative fields are
|
||||
untrusted even when their keys are allowlisted. Public prose must come from
|
||||
the separate transcript-only coaching call (or an explicitly marked safe
|
||||
persisted record), never from the hidden judge response.
|
||||
"""
|
||||
raw = verdict if isinstance(verdict, dict) else {}
|
||||
if public_debrief is None:
|
||||
public_debrief = raw.get("_public_debrief")
|
||||
public = public_debrief if isinstance(public_debrief, dict) else {}
|
||||
score = _bounded_int(raw.get("score", 0), default=0, minimum=0, maximum=100)
|
||||
return {
|
||||
"outcome": outcome,
|
||||
"score": score,
|
||||
# IP protection: do NOT surface the customer's pain (prose or raw list) to any
|
||||
# user-facing role. Pain drives the judge/training internally but is the core
|
||||
# "formula" of the coaching product, so it must not leak through the debrief.
|
||||
"why": _bounded_text(raw.get("why")),
|
||||
"failurePoints": _string_list(raw.get("failurePoints")),
|
||||
"coaching": _string_list(raw.get("coaching")),
|
||||
"revealed_persona": {
|
||||
"income": persona.get("income", ""),
|
||||
"personality": persona.get("personality", ""),
|
||||
"budget": persona.get("budget", ""),
|
||||
"opener": persona.get("opener", ""),
|
||||
"background": persona.get("background", ""),
|
||||
},
|
||||
"why": _bounded_text(public.get("why")),
|
||||
"failurePoints": _string_list(public.get("failurePoints")),
|
||||
"coaching": _string_list(public.get("coaching")),
|
||||
"revealed_persona": _safe_revealed_persona(persona),
|
||||
}
|
||||
|
||||
|
||||
def _safe_session_messages(value: object) -> list[dict[str, str]]:
|
||||
def _public_debrief(sim: object, messages: list[dict], outcome: str, score: int) -> dict:
|
||||
"""Ask for coaching using seller-only transcript data; fail closed."""
|
||||
generate = getattr(sim, "public_debrief", None)
|
||||
if not callable(generate):
|
||||
return {}
|
||||
safe_messages = [
|
||||
message
|
||||
for message in _safe_session_messages(messages)
|
||||
if message.get("role") == "seller"
|
||||
]
|
||||
try:
|
||||
result = generate(messages=safe_messages, outcome=outcome, score=score)
|
||||
except LLMError:
|
||||
return {}
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
|
||||
def _public_customer_opener(locale: object) -> str:
|
||||
return (
|
||||
"Hi, a customer has started the conversation."
|
||||
if locale == "en"
|
||||
else "ลูกค้าเริ่มต้นบทสนทนาแล้ว ลองทักและค้นหาความต้องการดูครับ"
|
||||
)
|
||||
|
||||
|
||||
_SAFE_SYSTEM_MESSAGES = frozenset({
|
||||
"⏳ ผ่านไป 2-3 สัปดาห์ ... ลูกค้าที่เคยสอบถามไปเงียบไประยะหนึ่ง ตอนนี้กลับมาติดต่ออีกครั้ง (พร้อมตัดสินใจมากขึ้น)",
|
||||
"⏳ 2-3 weeks later ... the customer who asked earlier went quiet; now they re-contact, more ready to decide.",
|
||||
"⏳ ลูกค้าตัดสินใจจะลองใช้สินค้า/บริการก่อน แล้วจะกลับมาติดต่ออีกครั้งเมื่อลองใช้แล้ว — ถือเป็นการสรุปการตัดสินใจจุดนี้ และปิด session อัตโนมัติ",
|
||||
"⏳ The customer decided to trial the product/service first and will come back once they have tried it — this decision point is closed and the session summarizes automatically.",
|
||||
})
|
||||
|
||||
|
||||
def _safe_session_messages(
|
||||
value: object,
|
||||
*,
|
||||
redact_initial_customer: bool = False,
|
||||
locale: object = "th",
|
||||
) -> list[dict[str, str]]:
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
allowed_roles = {"system", "seller", "customer", "assistant"}
|
||||
messages = []
|
||||
seller_seen = False
|
||||
for item in value[-100:]:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
role = item.get("role")
|
||||
text = item.get("text")
|
||||
if isinstance(role, str) and role in allowed_roles and isinstance(text, str):
|
||||
if not isinstance(text, str):
|
||||
continue
|
||||
if role == "system" and text not in _SAFE_SYSTEM_MESSAGES:
|
||||
continue
|
||||
if role in {"system", "seller", "customer"}:
|
||||
if role == "customer" and redact_initial_customer and not seller_seen:
|
||||
text = _public_customer_opener(locale)
|
||||
if role == "seller":
|
||||
seller_seen = True
|
||||
messages.append({"role": role, "text": text[:4000]})
|
||||
return messages
|
||||
|
||||
|
||||
def serialize_session(session: dict) -> dict:
|
||||
"""Return only the trainee-visible session envelope; omit hidden judge state."""
|
||||
meta = session.get("persona_meta")
|
||||
meta = meta if isinstance(meta, dict) else {}
|
||||
safe_meta = {
|
||||
key: meta[key]
|
||||
for key in ("tier", "initiation_mode", "channel", "scenario", "locale")
|
||||
if key in meta and isinstance(meta[key], (str, int, float, type(None)))
|
||||
}
|
||||
safe_meta = _safe_persona_meta(session.get("persona_meta"))
|
||||
outcome = session.get("outcome")
|
||||
if outcome not in (None, "won", "lost", "abandoned"):
|
||||
outcome = None
|
||||
@@ -161,19 +306,28 @@ def serialize_session(session: dict) -> dict:
|
||||
debrief = _safe_judge_debrief(raw_debrief, outcome or "lost", revealed)
|
||||
else:
|
||||
debrief = None
|
||||
mode = session.get("mode") or "trainee"
|
||||
if mode not in ("trainee", "preview"):
|
||||
raw_mode = session.get("mode")
|
||||
if raw_mode is None:
|
||||
mode = "trainee"
|
||||
elif raw_mode in ("trainee", "preview"):
|
||||
mode = raw_mode
|
||||
else:
|
||||
mode = None
|
||||
return {
|
||||
key: session.get(key)
|
||||
for key in ("id", "group_id", "persona_id", "persona_name", "created_at", "updated_at")
|
||||
if isinstance(session.get(key), str)
|
||||
} | {
|
||||
"persona_meta": safe_meta,
|
||||
"scenario": _safe_scenario(session.get("scenario")),
|
||||
"mode": mode,
|
||||
"status": session.get("status") if session.get("status") in ("active", "finished") else "active",
|
||||
"outcome": outcome,
|
||||
"messages": _safe_session_messages(session.get("messages")),
|
||||
"messages": _safe_session_messages(
|
||||
session.get("messages"),
|
||||
redact_initial_customer=True,
|
||||
locale=safe_meta["locale"],
|
||||
),
|
||||
"debrief": debrief,
|
||||
}
|
||||
|
||||
@@ -192,23 +346,31 @@ def _finalize_session(s, session: dict, group: dict, persona: dict) -> tuple[dic
|
||||
)
|
||||
return session, debrief
|
||||
|
||||
messages = list(session.get("messages", []))
|
||||
internal = session.get("internal", {})
|
||||
internal = dict(internal) if isinstance(internal, dict) else {}
|
||||
messages = _safe_session_messages(session.get("messages"))
|
||||
internal = _safe_roleplay_internal(session.get("internal"))
|
||||
# Persist the latest transcript before the provider call. A failed judge
|
||||
# therefore leaves an active, retryable session rather than losing turns.
|
||||
s["sessions"].update(session["id"], messages=messages, internal=internal)
|
||||
sim = _sim(group, persona)
|
||||
verdict = sim.judge(persona=persona, messages=messages, internal=internal)
|
||||
outcome = "won" if isinstance(verdict, dict) and verdict.get("outcome") == "won" else "lost"
|
||||
debrief = _safe_judge_debrief(verdict, outcome, persona)
|
||||
if not isinstance(verdict, dict):
|
||||
raise LLMError("judge returned an invalid response")
|
||||
outcome = "won" if verdict.get("outcome") == "won" else "lost"
|
||||
score = (
|
||||
_bounded_int(verdict.get("score", 0), default=0, minimum=0, maximum=100)
|
||||
if isinstance(verdict, dict)
|
||||
else 0
|
||||
)
|
||||
public_debrief = _public_debrief(sim, messages, outcome, score)
|
||||
debrief = _safe_judge_debrief(verdict, outcome, persona, public_debrief)
|
||||
stored_debrief = {**debrief, "_public_debrief": public_debrief}
|
||||
updated = s["sessions"].update(
|
||||
session["id"],
|
||||
status="finished",
|
||||
outcome=outcome,
|
||||
messages=messages,
|
||||
internal=internal,
|
||||
debrief=debrief,
|
||||
debrief=stored_debrief,
|
||||
)
|
||||
return updated, debrief
|
||||
|
||||
@@ -216,20 +378,106 @@ def _finalize_session(s, session: dict, group: dict, persona: dict) -> tuple[dic
|
||||
def _get_ready_group(s, gid: str) -> dict:
|
||||
"""Org-scoped group access for trainees + require ready status (IDOR defense)."""
|
||||
group = s["groups"].get_or_none(gid)
|
||||
if not isinstance(group, dict) or group.get("id") != gid or group.get("status") != "ready":
|
||||
raise ApiError("group not ready", 404)
|
||||
if not isinstance(group, dict) or group.get("id") != gid:
|
||||
raise ApiError("group not found", 404)
|
||||
actor = current_user()
|
||||
# super_admin can access any; otherwise owner (for personal groups) + same org.
|
||||
if actor.get("role") != "super_admin" and group.get("org_id") != actor.get("org_id"):
|
||||
raise ApiError("group not found", 404)
|
||||
if not is_valid_owner_visibility(group):
|
||||
raise ApiError("permission denied", 403)
|
||||
owner = group.get("owner_user_id")
|
||||
if actor.get("role") != "super_admin":
|
||||
actor_is_owner = is_canonical_private_owner(
|
||||
group,
|
||||
user_id=actor.get("id"),
|
||||
org_id=actor.get("org_id"),
|
||||
)
|
||||
if actor.get("role") == "super_admin" and "owner_user_id" in group and not actor_is_owner:
|
||||
raise ApiError("group not found", 404)
|
||||
if not is_ready_group(group):
|
||||
raise ApiError("group not ready", 404)
|
||||
# super_admin can access any shared group, never owner-private data. Demo
|
||||
# accounts are restricted to the dedicated
|
||||
# demo tenant and demo-visible shared groups. Tenant admins retain the
|
||||
# historical hidden-group preview path, while ordinary users can access
|
||||
# only public shared groups (or their own private group).
|
||||
visibility = group_visibility(group.get("visibility"))
|
||||
if actor.get("role") != "super_admin" and visibility is None:
|
||||
raise ApiError("permission denied", 403)
|
||||
if actor.get("role") == "demo":
|
||||
if (
|
||||
actor.get("org_id") != Config.DEMO_ORG_ID
|
||||
or group.get("org_id") != Config.DEMO_ORG_ID
|
||||
or visibility != "demo"
|
||||
or "owner_user_id" in group
|
||||
):
|
||||
raise ApiError("permission denied", 403)
|
||||
elif actor.get("role") == "admin":
|
||||
if (
|
||||
actor.get("org_id") != group.get("org_id")
|
||||
or ("owner_user_id" in group and not actor_is_owner)
|
||||
or (
|
||||
"owner_user_id" not in group
|
||||
and visibility not in {"public", "hidden"}
|
||||
)
|
||||
):
|
||||
raise ApiError("permission denied", 403)
|
||||
elif actor.get("role") != "super_admin":
|
||||
if "owner_user_id" in group:
|
||||
if not isinstance(owner, str) or not owner or owner != actor["id"]:
|
||||
raise ApiError("permission denied", 403)
|
||||
if "owner_user_id" not in group and visibility != "public":
|
||||
raise ApiError("permission denied", 403)
|
||||
if group.get("org_id") != actor.get("org_id"):
|
||||
raise ApiError("permission denied", 403)
|
||||
return group
|
||||
|
||||
|
||||
def _active_session_for_actor(s, actor: dict, *, gid: str, pid: str) -> dict | None:
|
||||
"""Return the actor's active session, preferring a real attempt.
|
||||
|
||||
Admin preview sessions are a separate scope from trainee attempts. Prefer a
|
||||
trainee session when both exist, then fall back to the admin-only preview
|
||||
scope so the hidden-group preview can actually be continued and finished.
|
||||
"""
|
||||
org_id = actor.get("org_id")
|
||||
user_id = actor.get("id")
|
||||
if not isinstance(org_id, str) or not isinstance(user_id, str):
|
||||
return None
|
||||
modes = ("trainee", "preview") if actor.get("role") == "admin" else ("trainee",)
|
||||
for mode in modes:
|
||||
session = s["sessions"].active_for_scope(
|
||||
org_id=org_id,
|
||||
user_id=user_id,
|
||||
group_id=gid,
|
||||
persona_id=pid,
|
||||
mode=mode,
|
||||
)
|
||||
if session:
|
||||
return session
|
||||
return None
|
||||
|
||||
|
||||
def _latest_session_for_actor(s, actor: dict, *, gid: str, pid: str) -> dict | None:
|
||||
"""Return the newest session in the actor's allowed modes."""
|
||||
org_id = actor.get("org_id")
|
||||
user_id = actor.get("id")
|
||||
if not isinstance(org_id, str) or not isinstance(user_id, str):
|
||||
return None
|
||||
modes = ("trainee", "preview") if actor.get("role") == "admin" else ("trainee",)
|
||||
candidates = []
|
||||
for mode in modes:
|
||||
session = s["sessions"].latest_for_scope(
|
||||
org_id=org_id,
|
||||
user_id=user_id,
|
||||
group_id=gid,
|
||||
persona_id=pid,
|
||||
mode=mode,
|
||||
)
|
||||
if session:
|
||||
candidates.append(session)
|
||||
return max(candidates, key=lambda row: row.get("updated_at", ""), default=None)
|
||||
|
||||
|
||||
def _authorize_session_context(
|
||||
s,
|
||||
session: object,
|
||||
@@ -252,7 +500,11 @@ def _authorize_session_context(
|
||||
org_id = actor.get("org_id")
|
||||
# Both trainee (one-shot) and admin preview sessions are authorized here;
|
||||
# the group context re-check below still locks out non-owners/foreign orgs.
|
||||
expected_modes = ("trainee", "preview")
|
||||
expected_modes = (
|
||||
("trainee", "preview")
|
||||
if actor.get("role") in {"admin", "super_admin"}
|
||||
else ("trainee",)
|
||||
)
|
||||
session_mode = session.get("mode")
|
||||
if session_mode is None:
|
||||
session_mode = "trainee" # legacy rows before explicit mode was added
|
||||
@@ -279,12 +531,15 @@ def _authorize_session_context(
|
||||
raise ApiError("session not found", 404)
|
||||
|
||||
try:
|
||||
group = _get_ready_group(s, session_gid)
|
||||
with s["groups"].record_lock(session_gid):
|
||||
group = _get_ready_group(s, session_gid)
|
||||
persona = s["groups"].get_persona(session_gid, session_pid)
|
||||
except ApiError as exc:
|
||||
if exc.status in (403, 404):
|
||||
raise ApiError("session not found", 404) from exc
|
||||
raise
|
||||
persona = s["groups"].get_persona(session_gid, session_pid)
|
||||
except StoreNotFoundError as exc:
|
||||
raise ApiError("session not found", 404) from exc
|
||||
if not isinstance(persona, dict):
|
||||
raise ApiError("session context missing", 404)
|
||||
return group, persona
|
||||
@@ -292,12 +547,12 @@ def _authorize_session_context(
|
||||
|
||||
@chat_bp.post("/<gid>/personas/<pid>/chat/start")
|
||||
@require_auth
|
||||
@require_roles("user", "admin")
|
||||
@require_roles("user", "admin", "demo")
|
||||
def start_session(gid: str, pid: str):
|
||||
s = _stores()
|
||||
group = _get_ready_group(s, gid)
|
||||
persona = s["groups"].get_persona(gid, pid)
|
||||
if not persona:
|
||||
if not isinstance(persona, dict):
|
||||
raise ApiError("persona not found", 404)
|
||||
actor = current_user()
|
||||
org_id = actor.get("org_id")
|
||||
@@ -308,25 +563,25 @@ def start_session(gid: str, pid: str):
|
||||
requested_mode = body.get("mode", "trainee")
|
||||
if requested_mode not in ("trainee", "preview"):
|
||||
raise ApiError("session mode is invalid", 400)
|
||||
if actor.get("role") == "demo" and requested_mode == "preview":
|
||||
raise ApiError("demo accounts may only use trainee sessions", 403)
|
||||
# Preview mode exists so an admin can safely try a HIDDEN (draft) shared
|
||||
# product without consuming a trainee attempt or polluting trainee
|
||||
# analytics. For any other group/role, requesting 'preview' is coerced to a
|
||||
# real one-shot trainee session (previous behavior).
|
||||
group_visibility = (group.get("visibility") or "public")
|
||||
group_visibility_value = group_visibility(group.get("visibility"))
|
||||
if requested_mode == "preview" and not (
|
||||
actor.get("role") == "admin"
|
||||
and group_visibility == "hidden"
|
||||
and group_visibility_value == "hidden"
|
||||
and "owner_user_id" not in group
|
||||
):
|
||||
requested_mode = "trainee"
|
||||
scenario_raw = body.get("scenario", "social")
|
||||
scenario = scenario_raw.strip().lower() if isinstance(scenario_raw, str) else "social"
|
||||
if scenario not in ("social", "f2f_call"):
|
||||
scenario = "social"
|
||||
scenario = _safe_scenario(
|
||||
scenario_raw.strip().lower() if isinstance(scenario_raw, str) else scenario_raw
|
||||
)
|
||||
locale_raw = body.get("locale", "th")
|
||||
locale = locale_raw.strip().lower() if isinstance(locale_raw, str) else "th"
|
||||
if locale not in ("en", "th"):
|
||||
locale = "th"
|
||||
locale = _safe_locale(locale_raw)
|
||||
scenario_meta, init_mode = _scenario_config(scenario, persona, locale)
|
||||
|
||||
# Build the complete initial record before publishing it. A concurrent send
|
||||
@@ -335,8 +590,7 @@ def start_session(gid: str, pid: str):
|
||||
if scenario_meta.get("preamble"):
|
||||
seeded.append({"role": "system", "text": scenario_meta["preamble"]})
|
||||
if init_mode == "customer":
|
||||
opener = persona.get("opener") or (("สวัสดีครับ สนใจสินค้าของคุณครับ ขอถามรายละเอียดหน่อยได้ไหม" if locale == "th" else "Hi, I saw your product and had a question."))
|
||||
seeded.append({"role": "customer", "text": opener})
|
||||
seeded.append({"role": "customer", "text": _public_customer_opener(locale)})
|
||||
initial_internal = {"turns": 0, "score": 50, "signals": []}
|
||||
|
||||
# RESUME/create is one atomic operation over the complete tenant scope. A
|
||||
@@ -356,8 +610,7 @@ def start_session(gid: str, pid: str):
|
||||
if scenario_meta.get("preamble"):
|
||||
seeded.append({"role": "system", "text": scenario_meta["preamble"]})
|
||||
if init_mode == "customer":
|
||||
opener = persona.get("opener") or (("สวัสดีครับ สนใจสินค้าของคุณครับ ขอถามรายละเอียดหน่อยได้ไหม" if locale == "th" else "Hi, I saw your product and had a question."))
|
||||
seeded.append({"role": "customer", "text": opener})
|
||||
seeded.append({"role": "customer", "text": _public_customer_opener(locale)})
|
||||
session, resumed = s["sessions"].start(
|
||||
org_id=org_id,
|
||||
user_id=actor["id"],
|
||||
@@ -381,12 +634,14 @@ def start_session(gid: str, pid: str):
|
||||
_authorize_session_context(
|
||||
s, session, gid=gid, pid=pid, required_status="active"
|
||||
)
|
||||
existing_scenario = session.get("scenario") or (
|
||||
session.get("persona_meta") or {}
|
||||
).get("scenario") or "social"
|
||||
existing_locale = session.get("locale") or (
|
||||
session.get("persona_meta") or {}
|
||||
).get("locale") or "th"
|
||||
existing_scenario = _safe_scenario(
|
||||
session.get("scenario")
|
||||
or (session.get("persona_meta") or {}).get("scenario")
|
||||
)
|
||||
existing_locale = _safe_locale(
|
||||
session.get("locale")
|
||||
or (session.get("persona_meta") or {}).get("locale")
|
||||
)
|
||||
existing_meta, existing_init = _scenario_config(
|
||||
existing_scenario, persona, existing_locale
|
||||
)
|
||||
@@ -394,7 +649,7 @@ def start_session(gid: str, pid: str):
|
||||
"session": serialize_session(session),
|
||||
"initiation_mode": existing_init,
|
||||
"scenario": existing_scenario,
|
||||
"scenario_meta": existing_meta,
|
||||
"scenario_meta": _public_scenario_meta(existing_meta),
|
||||
}
|
||||
except ValueError as exc:
|
||||
raise internal_error("could not start session", exc, 400)
|
||||
@@ -407,13 +662,13 @@ def start_session(gid: str, pid: str):
|
||||
"session": serialize_session(sess),
|
||||
"initiation_mode": init_mode,
|
||||
"scenario": scenario,
|
||||
"scenario_meta": scenario_meta,
|
||||
"scenario_meta": _public_scenario_meta(scenario_meta),
|
||||
})
|
||||
|
||||
|
||||
@chat_bp.post("/<gid>/personas/<pid>/chat/send")
|
||||
@require_auth
|
||||
@require_roles("user", "admin")
|
||||
@require_roles("user", "admin", "demo")
|
||||
@_session_mutation
|
||||
def send_message(gid: str, pid: str):
|
||||
s = _stores()
|
||||
@@ -421,13 +676,7 @@ def send_message(gid: str, pid: str):
|
||||
org_id = actor.get("org_id")
|
||||
if not isinstance(org_id, str) or not org_id:
|
||||
raise ApiError("permission denied", 403)
|
||||
session = s["sessions"].active_for_scope(
|
||||
org_id=org_id,
|
||||
user_id=actor["id"],
|
||||
group_id=gid,
|
||||
persona_id=pid,
|
||||
mode="trainee",
|
||||
)
|
||||
session = _active_session_for_actor(s, actor, gid=gid, pid=pid)
|
||||
if not session:
|
||||
raise ApiError("no active session for this persona", 404)
|
||||
|
||||
@@ -452,10 +701,10 @@ def send_message(gid: str, pid: str):
|
||||
if not ratelimit("chat:user", actor_rl.get("id") or actor_rl.get("username") or "?", limit=30, window=60):
|
||||
raise ApiError("slow down — too many messages", 429)
|
||||
|
||||
messages = list(session.get("messages", []))
|
||||
messages = _safe_session_messages(session.get("messages"))
|
||||
messages.append({"role": "seller", "text": text})
|
||||
scenario = session.get("scenario", "social") or "social"
|
||||
slocale = session.get("locale", "th") or "th"
|
||||
scenario = _safe_scenario(session.get("scenario"))
|
||||
slocale = _safe_locale(session.get("locale"))
|
||||
adapt = _scenarios(slocale).get(scenario, _scenarios(slocale)["social"]).get("adapt", "")
|
||||
|
||||
sim = _sim(group, persona)
|
||||
@@ -464,7 +713,7 @@ def send_message(gid: str, pid: str):
|
||||
persona=persona,
|
||||
sales_kit=group.get("sales_kit") or {},
|
||||
messages=messages,
|
||||
internal=session.get("internal", {}),
|
||||
internal=_safe_roleplay_internal(session.get("internal")),
|
||||
scenario=scenario,
|
||||
scenario_adapt=adapt,
|
||||
)
|
||||
@@ -473,7 +722,7 @@ def send_message(gid: str, pid: str):
|
||||
messages.append({"role": "customer", "text": reply})
|
||||
|
||||
# Update internal state: track misses (poor answers) and mood trend.
|
||||
internal = session.get("internal", {}) or {}
|
||||
internal = _safe_roleplay_internal(session.get("internal"))
|
||||
internal.setdefault("turns", 0)
|
||||
internal["turns"] = internal.get("turns", 0) + 1
|
||||
internal["signals"] = internal.get("signals", [])
|
||||
@@ -495,20 +744,23 @@ def send_message(gid: str, pid: str):
|
||||
# Evaluate this turn via the (judge) LLM: how the persona feels + whether it has decided.
|
||||
# This is context-based (NOT fixed keywords), so e.g. "ซื้อไม่ไหว แต่ว่ามีผ่อนไหม?" stays
|
||||
# pending until the customer truly commits to (or abandons) the decision.
|
||||
turn_eval = sim.evaluate_turn(
|
||||
persona=persona, messages=messages, internal=internal
|
||||
)
|
||||
# Save the reply before the second provider call so an evaluation failure
|
||||
# leaves an active, retryable session rather than losing the turn.
|
||||
s["sessions"].update(session["id"], messages=messages, internal=internal)
|
||||
try:
|
||||
turn_eval = sim.evaluate_turn(
|
||||
persona=persona, messages=messages, internal=internal
|
||||
)
|
||||
except LLMError as exc:
|
||||
raise internal_error("LLM service unavailable", exc)
|
||||
if not isinstance(turn_eval, dict):
|
||||
raise internal_error("LLM service unavailable", ValueError("invalid turn evaluation"))
|
||||
decision = turn_eval.get("decision", "pending")
|
||||
try:
|
||||
mood = int(turn_eval.get("mood", 0))
|
||||
except (TypeError, ValueError):
|
||||
mood = 0
|
||||
mood = _bounded_int(turn_eval.get("mood", 0), default=0, minimum=-100, maximum=100)
|
||||
# Apply the judge's score delta to internal score trend.
|
||||
try:
|
||||
sd = int(turn_eval.get("score_delta", 0))
|
||||
except (TypeError, ValueError):
|
||||
sd = 0
|
||||
internal["score"] = max(0, min(100, int(internal.get("score", 50)) + sd))
|
||||
sd = _bounded_int(turn_eval.get("score_delta", 0), default=0, minimum=-100, maximum=100)
|
||||
current_score = _bounded_int(internal.get("score", 50), default=50, minimum=0, maximum=100)
|
||||
internal["score"] = max(0, min(100, current_score + sd))
|
||||
internal["last_reason"] = turn_eval.get("reason", "")
|
||||
# Track mood trend for debrief.
|
||||
if mood <= -1:
|
||||
@@ -531,16 +783,21 @@ def send_message(gid: str, pid: str):
|
||||
"automatically."
|
||||
)
|
||||
messages.append({"role": "system", "text": try_note})
|
||||
finalized, debrief = _finalize_session(
|
||||
s,
|
||||
{**session, "messages": messages, "internal": internal},
|
||||
group,
|
||||
persona,
|
||||
)
|
||||
try:
|
||||
finalized, debrief = _finalize_session(
|
||||
s,
|
||||
{**session, "messages": messages, "internal": internal},
|
||||
group,
|
||||
persona,
|
||||
)
|
||||
except LLMError as exc:
|
||||
raise internal_error("LLM service unavailable", exc)
|
||||
outcome = finalized.get("outcome")
|
||||
return jsonify({
|
||||
"reply": reply,
|
||||
"messages": messages,
|
||||
"messages": _safe_session_messages(
|
||||
messages, redact_initial_customer=True, locale=slocale
|
||||
),
|
||||
"finished": True,
|
||||
"outcome": outcome,
|
||||
"debrief": debrief,
|
||||
@@ -548,12 +805,17 @@ def send_message(gid: str, pid: str):
|
||||
})
|
||||
|
||||
s["sessions"].update(session["id"], messages=messages, internal=internal)
|
||||
return jsonify({"reply": reply, "messages": messages})
|
||||
return jsonify({
|
||||
"reply": reply,
|
||||
"messages": _safe_session_messages(
|
||||
messages, redact_initial_customer=True, locale=slocale
|
||||
),
|
||||
})
|
||||
|
||||
|
||||
@chat_bp.post("/<gid>/personas/<pid>/chat/finish")
|
||||
@require_auth
|
||||
@require_roles("user", "admin")
|
||||
@require_roles("user", "admin", "demo")
|
||||
@_session_mutation
|
||||
def finish_session(gid: str, pid: str):
|
||||
"""End the chat and produce the debrief via the judge-LLM (reveals latent fields)."""
|
||||
@@ -562,21 +824,9 @@ def finish_session(gid: str, pid: str):
|
||||
org_id = actor.get("org_id")
|
||||
if not isinstance(org_id, str) or not org_id:
|
||||
raise ApiError("permission denied", 403)
|
||||
session = s["sessions"].active_for_scope(
|
||||
org_id=org_id,
|
||||
user_id=actor["id"],
|
||||
group_id=gid,
|
||||
persona_id=pid,
|
||||
mode="trainee",
|
||||
)
|
||||
session = _active_session_for_actor(s, actor, gid=gid, pid=pid)
|
||||
if not session:
|
||||
finished = s["sessions"].latest_for_scope(
|
||||
org_id=org_id,
|
||||
user_id=actor["id"],
|
||||
group_id=gid,
|
||||
persona_id=pid,
|
||||
mode="trainee",
|
||||
)
|
||||
finished = _latest_session_for_actor(s, actor, gid=gid, pid=pid)
|
||||
if finished and finished.get("status") == "finished":
|
||||
_group, persona = _authorize_session_context(
|
||||
s, finished, gid=gid, pid=pid, required_status="finished"
|
||||
@@ -624,7 +874,7 @@ def my_sessions():
|
||||
|
||||
@chat_bp.get("/sessions/<sid>")
|
||||
@require_auth
|
||||
@require_roles("user", "admin")
|
||||
@require_roles("user", "admin", "demo")
|
||||
def get_session(sid: str):
|
||||
s = _stores()
|
||||
session = s["sessions"].get_or_none(sid)
|
||||
@@ -641,7 +891,7 @@ def get_session(sid: str):
|
||||
|
||||
@chat_bp.get("/<gid>/personas/<pid>/chat/resume")
|
||||
@require_auth
|
||||
@require_roles("user", "admin")
|
||||
@require_roles("user", "admin", "demo")
|
||||
def resume_session(gid: str, pid: str):
|
||||
"""Resume an active (unfinished) session for this persona so the trainee can continue."""
|
||||
s = _stores()
|
||||
@@ -649,16 +899,16 @@ def resume_session(gid: str, pid: str):
|
||||
org_id = actor.get("org_id")
|
||||
if not isinstance(org_id, str) or not org_id:
|
||||
raise ApiError("permission denied", 403)
|
||||
session = s["sessions"].active_for_scope(
|
||||
org_id=org_id,
|
||||
user_id=actor["id"],
|
||||
group_id=gid,
|
||||
persona_id=pid,
|
||||
mode="trainee",
|
||||
)
|
||||
session = _active_session_for_actor(s, actor, gid=gid, pid=pid)
|
||||
if not session:
|
||||
raise ApiError("no active session for this persona", 404)
|
||||
_authorize_session_context(
|
||||
s, session, gid=gid, pid=pid, required_status="active"
|
||||
)
|
||||
return jsonify({"session": serialize_session(session), "scenario": session.get("scenario", "social")})
|
||||
safe_scenario = _safe_scenario(session.get("scenario"))
|
||||
safe_locale = _safe_locale(session.get("locale"))
|
||||
if session.get("scenario") != safe_scenario or session.get("locale") != safe_locale:
|
||||
session = s["sessions"].update(
|
||||
session["id"], scenario=safe_scenario, locale=safe_locale
|
||||
)
|
||||
return jsonify({"session": serialize_session(session), "scenario": safe_scenario})
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Group API: create, analyze (sales kit + personas), read, edit, report."""
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import functools
|
||||
import math
|
||||
import threading
|
||||
@@ -12,7 +13,14 @@ from werkzeug.exceptions import RequestEntityTooLarge
|
||||
|
||||
from ..config import Config
|
||||
from ..llm import LLMClient, LLMError
|
||||
from ..services.groups import GroupStore
|
||||
from ..services.groups import (
|
||||
GroupStore,
|
||||
group_visibility,
|
||||
is_canonical_private_owner,
|
||||
is_ready_group,
|
||||
is_valid_owner_visibility,
|
||||
safe_group_input,
|
||||
)
|
||||
from ..services.store import (
|
||||
PERSONA_CHANNELS,
|
||||
ensure_persona_shape,
|
||||
@@ -62,10 +70,22 @@ ADMIN_EDITABLE_PERSONA_FIELDS = ADMIN_VISIBLE_PERSONA_FIELDS - {"id"}
|
||||
# Keep the group envelope closed as well as the nested persona envelope. New
|
||||
# internal fields must be deliberately added here before they can cross the API.
|
||||
GROUP_VISIBLE_FIELDS = {
|
||||
"id", "org_id", "creator_id", "owner_user_id", "title", "status",
|
||||
"id", "org_id", "title", "status",
|
||||
"visibility", "created_at", "updated_at", "input", "sales_kit", "personas", "report", "error",
|
||||
}
|
||||
|
||||
GROUP_INPUT_LIMITS = {
|
||||
"product": 2000,
|
||||
"segment": 2000,
|
||||
"description": 4000,
|
||||
"channel": 32,
|
||||
"language": 16,
|
||||
}
|
||||
GROUP_INPUT_ENUMS = {
|
||||
"channel": {"facebook", "line", "social"},
|
||||
"language": {"th", "en"},
|
||||
}
|
||||
|
||||
|
||||
def strip_secret_fields(persona: dict) -> dict:
|
||||
"""Return only explicitly approved tenant-admin persona fields."""
|
||||
@@ -76,50 +96,72 @@ def strip_secret_fields(persona: dict) -> dict:
|
||||
}
|
||||
|
||||
|
||||
def _validated_persona(persona: object) -> dict | None:
|
||||
"""Normalize one persona only when its id and behavior traits are valid."""
|
||||
if not isinstance(persona, dict):
|
||||
return None
|
||||
try:
|
||||
shaped = ensure_persona_shape(persona)
|
||||
if not isinstance(shaped.get("id"), str) or not shaped["id"].strip():
|
||||
return None
|
||||
shaped["id"] = shaped["id"].strip()
|
||||
validate_persona_traits(shaped)
|
||||
except (AttributeError, TypeError, ValueError):
|
||||
return None
|
||||
return shaped
|
||||
|
||||
|
||||
def serialize_persona(persona: dict, actor: dict) -> dict:
|
||||
"""Serialize a persona through the single role-aware policy."""
|
||||
shaped = ensure_persona_shape(persona)
|
||||
shaped = _validated_persona(persona)
|
||||
if shaped is None:
|
||||
raise ApiError("persona not found", 404)
|
||||
if actor.get("role") == "super_admin":
|
||||
return shaped
|
||||
if actor.get("role") == "user":
|
||||
if actor.get("role") in {"user", "demo"}:
|
||||
return revealable_view(shaped)
|
||||
return strip_secret_fields(shaped)
|
||||
|
||||
|
||||
def _canonical_personas(value: object) -> list[dict]:
|
||||
"""Keep only persona records that can be addressed consistently by id."""
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [persona for raw in value if (persona := _validated_persona(raw)) is not None]
|
||||
|
||||
|
||||
def serialize_group(group: dict, actor: dict) -> dict:
|
||||
"""Serialize a group without exposing latent persona recipe data."""
|
||||
raw_input = group.get("input")
|
||||
source = raw_input if isinstance(raw_input, dict) else {}
|
||||
raw_personas = group.get("personas")
|
||||
personas = raw_personas if isinstance(raw_personas, list) else []
|
||||
source = safe_group_input(group.get("input"))
|
||||
personas = _canonical_personas(group.get("personas"))
|
||||
view = {
|
||||
field: group[field]
|
||||
for field in GROUP_VISIBLE_FIELDS
|
||||
if field in group
|
||||
}
|
||||
view["is_owned"] = (
|
||||
is_canonical_private_owner(
|
||||
group,
|
||||
user_id=actor.get("id"),
|
||||
org_id=actor.get("org_id"),
|
||||
)
|
||||
)
|
||||
# Historical groups may contain legacy raw exception text. Normalize at the API
|
||||
# boundary so old records cannot leak paths/provider responses either.
|
||||
if view.get("error"):
|
||||
view["error"] = "analysis_failed" if view.get("status") == "failed" else "operation_failed"
|
||||
view["personas"] = [
|
||||
serialize_persona(persona, actor) for persona in personas if isinstance(persona, dict)
|
||||
]
|
||||
if actor.get("role") == "user":
|
||||
# Trainees need only the high-level offer context. Do not return uploaded
|
||||
# filenames or parsed source text from the admin's group brief.
|
||||
view["input"] = {
|
||||
key: source[key]
|
||||
for key in ("product", "segment", "channel", "language")
|
||||
if key in source
|
||||
}
|
||||
elif actor.get("role") != "super_admin":
|
||||
# Tenant admins can manage personas, but uploaded filenames, parsed source
|
||||
# text, and future internal input fields remain server-side only.
|
||||
view["input"] = {
|
||||
key: source[key]
|
||||
for key in ("product", "segment", "channel", "language")
|
||||
if key in source
|
||||
}
|
||||
view["personas"] = (
|
||||
[
|
||||
serialize_persona(persona, actor)
|
||||
for persona in personas
|
||||
]
|
||||
if group.get("status") == "ready"
|
||||
else []
|
||||
)
|
||||
# Every client role receives only the bounded high-level input allowlist;
|
||||
# uploaded filenames, parsed source text, and future internal fields stay
|
||||
# server-side even for privileged API callers.
|
||||
view["input"] = source
|
||||
if actor.get("role") != "super_admin":
|
||||
view["sales_kit"] = None
|
||||
view["report"] = None
|
||||
@@ -129,6 +171,31 @@ _ANALYZE_LOCKS: dict[str, threading.Lock] = {}
|
||||
_ANALYZE_GUARD = threading.Lock()
|
||||
|
||||
|
||||
def _merge_preserved_variants(
|
||||
existing: object, generated: list[dict]
|
||||
) -> list[dict]:
|
||||
"""Keep accepted variants when a ready group is re-analyzed.
|
||||
|
||||
Variants carry ``source_persona_id`` and are deliberate user-visible work.
|
||||
Analysis replaces the generated base set, but must not silently discard those
|
||||
appended records when the variant won the record lock just before analysis.
|
||||
"""
|
||||
generated_ids = {
|
||||
persona.get("id")
|
||||
for persona in generated
|
||||
if isinstance(persona, dict) and isinstance(persona.get("id"), str)
|
||||
}
|
||||
prior_variants = [
|
||||
persona
|
||||
for raw in (existing if isinstance(existing, list) else [])
|
||||
if (persona := _validated_persona(raw)) is not None
|
||||
and isinstance(persona.get("source_persona_id"), str)
|
||||
and bool(persona["source_persona_id"].strip())
|
||||
and persona.get("id") not in generated_ids
|
||||
]
|
||||
return generated + prior_variants
|
||||
|
||||
|
||||
def _stores():
|
||||
from flask import current_app
|
||||
|
||||
@@ -143,41 +210,81 @@ def _stores():
|
||||
def _authorize_group(group: dict) -> None:
|
||||
"""Enforce org-scoped access (IDOR defense). super_admin may access any org.
|
||||
|
||||
Private/personal groups (owner_user_id set) are only accessible by their owner
|
||||
(or super_admin), even within the same org.
|
||||
Private/personal groups (owner_user_id set) are only accessible by their owner,
|
||||
even to platform super-admins. Platform administration applies only to shared
|
||||
records; an owner must explicitly share data before privileged access exists.
|
||||
"""
|
||||
if not isinstance(group, dict):
|
||||
raise ApiError("group state unavailable", 503)
|
||||
raise ApiError("group not found", 404)
|
||||
actor = current_user()
|
||||
if actor.get("role") == "super_admin":
|
||||
return
|
||||
if group_visibility(group.get("visibility")) is None:
|
||||
raise ApiError("group not found", 404)
|
||||
owner_marker_present = "owner_user_id" in group
|
||||
owner = group.get("owner_user_id")
|
||||
actor_is_owner = is_canonical_private_owner(
|
||||
group,
|
||||
user_id=actor.get("id"),
|
||||
org_id=actor.get("org_id"),
|
||||
)
|
||||
if actor_is_owner:
|
||||
return
|
||||
if actor.get("role") == "super_admin":
|
||||
if not is_valid_owner_visibility(group) or "owner_user_id" in group:
|
||||
raise ApiError("group not found", 404)
|
||||
return
|
||||
if actor.get("role") == "demo":
|
||||
if (
|
||||
actor.get("org_id") != Config.DEMO_ORG_ID
|
||||
or group.get("org_id") != Config.DEMO_ORG_ID
|
||||
or group.get("visibility") != "demo"
|
||||
or "owner_user_id" in group
|
||||
):
|
||||
raise ApiError("group not found", 404)
|
||||
return
|
||||
if not is_valid_tenant_id(actor.get("org_id")) or not is_valid_tenant_id(group.get("org_id")):
|
||||
raise ApiError("group not found", 404)
|
||||
if group.get("org_id") != actor.get("org_id"):
|
||||
raise ApiError("group not found", 404)
|
||||
if not is_valid_owner_visibility(group):
|
||||
raise ApiError("permission denied", 403)
|
||||
if actor.get("role") == "admin" and (
|
||||
owner_marker_present or group_visibility(group.get("visibility")) not in {"public", "hidden"}
|
||||
):
|
||||
raise ApiError("permission denied", 403)
|
||||
if owner_marker_present and (
|
||||
not isinstance(owner, str) or not owner.strip() or owner != actor.get("id")
|
||||
):
|
||||
raise ApiError("permission denied", 403)
|
||||
if not is_valid_tenant_id(actor.get("org_id")) or not is_valid_tenant_id(group.get("org_id")):
|
||||
raise ApiError("permission denied", 403)
|
||||
if group.get("org_id") != actor.get("org_id"):
|
||||
raise ApiError("permission denied", 403)
|
||||
|
||||
|
||||
def _get_owned_group(s, gid: str) -> dict:
|
||||
def _get_owned_group(s, gid: str, *, require_ready: bool = True) -> dict:
|
||||
group = s["groups"].get_or_none(gid)
|
||||
if group is None:
|
||||
raise ApiError("group not found", 404)
|
||||
if not isinstance(group, dict):
|
||||
raise ApiError("group state unavailable", 503)
|
||||
raise ApiError("group not found", 404)
|
||||
_authorize_group(group)
|
||||
if current_user().get("role") == "user":
|
||||
# A trainee may not access a shared HIDDEN group directly; hidden
|
||||
# products are admin-preview only. (Public shared groups stay reachable
|
||||
# for trainees, e.g. to spawn private persona variants; their own
|
||||
# private groups require the ready gate below.)
|
||||
if "owner_user_id" not in group and (group.get("visibility") or "public") == "hidden":
|
||||
status = group.get("status")
|
||||
if status not in {"draft", "analyzing", "ready", "failed"}:
|
||||
raise ApiError("group not found", 404)
|
||||
if status == "ready" and not is_ready_group(group):
|
||||
raise ApiError("group not ready", 403)
|
||||
if current_user().get("role") in {"user", "demo"}:
|
||||
# Trainees may read only ready groups. Normal trainees see public shared
|
||||
# groups and their own private groups; demos see only demo-visible groups.
|
||||
allowed_visibility = "demo" if current_user().get("role") == "demo" else "public"
|
||||
visibility = group_visibility(group.get("visibility"))
|
||||
if visibility is None:
|
||||
raise ApiError("permission denied", 403)
|
||||
if group.get("status") != "ready":
|
||||
if "owner_user_id" not in group and visibility != allowed_visibility:
|
||||
raise ApiError("permission denied", 403)
|
||||
owner_can_reopen = (
|
||||
not require_ready
|
||||
and current_user().get("role") == "user"
|
||||
and group.get("owner_user_id") == current_user().get("id")
|
||||
and group.get("status") in {"draft", "failed", "analyzing"}
|
||||
)
|
||||
if status != "ready" and not owner_can_reopen:
|
||||
raise ApiError("group not ready", 403)
|
||||
return group
|
||||
|
||||
@@ -365,7 +472,13 @@ def create_group():
|
||||
value = data.get(field)
|
||||
if value is not None and not isinstance(value, str):
|
||||
raise ApiError(f"{field} must be a string", 400)
|
||||
string_fields[field] = (value if value is not None else default).strip()
|
||||
normalized = (value if value is not None else default).strip()
|
||||
if len(normalized) > GROUP_INPUT_LIMITS[field]:
|
||||
raise ApiError(f"{field} is too long", 400)
|
||||
allowed = GROUP_INPUT_ENUMS.get(field)
|
||||
if allowed is not None and normalized not in allowed:
|
||||
raise ApiError(f"{field} is invalid", 400)
|
||||
string_fields[field] = normalized
|
||||
product = string_fields["product"]
|
||||
if product == "" and not file_text.strip():
|
||||
raise ApiError("provide product info in the form or via file upload")
|
||||
@@ -376,30 +489,59 @@ def create_group():
|
||||
is_trainee = actor.get("role") == "user"
|
||||
|
||||
# Trainees always create a PRIVATE group owned by themselves. Admins
|
||||
# create org-shared products with an explicit visibility (default public).
|
||||
# create org-shared products. Only a super_admin may target another
|
||||
# organization or publish a demo-visible group.
|
||||
visibility_raw = data.get("visibility")
|
||||
requested_org_raw = data.get("org_id")
|
||||
if is_trainee:
|
||||
owner_user_id = actor.get("id")
|
||||
visibility = "private"
|
||||
target_org_id = actor_org_id
|
||||
else:
|
||||
owner_user_id = None
|
||||
if visibility_raw is not None and not isinstance(visibility_raw, str):
|
||||
raise ApiError("visibility must be a string", 400)
|
||||
visibility = (visibility_raw or "public").strip()
|
||||
if visibility not in ("public", "hidden"):
|
||||
visibility = "public" if visibility_raw is None else visibility_raw.strip()
|
||||
if actor.get("role") == "super_admin":
|
||||
if requested_org_raw is not None and not isinstance(requested_org_raw, str):
|
||||
raise ApiError("org_id must be a string", 400)
|
||||
target_org_id = (requested_org_raw or actor_org_id).strip()
|
||||
if visibility == "demo":
|
||||
if requested_org_raw is not None and target_org_id != Config.DEMO_ORG_ID:
|
||||
raise ApiError("demo groups must use the demo organization", 403)
|
||||
target_org_id = Config.DEMO_ORG_ID
|
||||
elif not is_valid_tenant_id(target_org_id):
|
||||
raise ApiError("invalid organization", 400)
|
||||
else:
|
||||
if requested_org_raw is not None and requested_org_raw != actor_org_id:
|
||||
raise ApiError("permission denied", 403)
|
||||
target_org_id = actor_org_id
|
||||
allowed_visibility = ("public", "hidden", "demo") if actor.get("role") == "super_admin" else ("public", "hidden")
|
||||
if visibility not in allowed_visibility:
|
||||
raise ApiError("visibility must be 'public' or 'hidden'")
|
||||
if visibility == "demo" and target_org_id != Config.DEMO_ORG_ID:
|
||||
raise ApiError("demo groups must use the demo organization", 403)
|
||||
|
||||
if not is_valid_tenant_id(target_org_id):
|
||||
raise ApiError("permission denied", 403)
|
||||
if visibility == "demo":
|
||||
try:
|
||||
s["users"].ensure_demo_org()
|
||||
except Exception as exc:
|
||||
raise internal_error("demo organization unavailable", exc, 503)
|
||||
else:
|
||||
target_org = s["users"].get_org_or_none(target_org_id)
|
||||
if not target_org or target_org.get("active") is not True:
|
||||
raise ApiError("organization is inactive or missing", 403)
|
||||
|
||||
try:
|
||||
group = s["groups"].create(
|
||||
org_id=actor_org_id,
|
||||
org_id=target_org_id,
|
||||
creator_id=actor["id"],
|
||||
title=(product or file_text[:80] or "Untitled group").strip()[:200],
|
||||
owner_user_id=owner_user_id,
|
||||
visibility=visibility,
|
||||
)
|
||||
s["groups"].update(
|
||||
group["id"],
|
||||
input={
|
||||
input_data={
|
||||
"product": product,
|
||||
"segment": string_fields["segment"],
|
||||
"description": string_fields["description"],
|
||||
@@ -436,6 +578,7 @@ def list_groups():
|
||||
# roles remain constrained to the authenticated organization's scope.
|
||||
org_id=None if actor.get("role") == "super_admin" else actor.get("org_id"),
|
||||
user_id=actor.get("id"),
|
||||
actor_org_id=actor.get("org_id"),
|
||||
)
|
||||
# ``list_visible_to`` applies the trainee owner/redaction policy at the
|
||||
# service boundary; keep this route limited to lightweight summaries.
|
||||
@@ -443,15 +586,14 @@ def list_groups():
|
||||
# list view (huge, heavy, leaks the recipe). Training shows title + persona count.
|
||||
summaries = []
|
||||
for g in visible:
|
||||
raw_personas = g.get("personas")
|
||||
personas = raw_personas if isinstance(raw_personas, list) else []
|
||||
raw_input = g.get("input")
|
||||
group_input = raw_input if isinstance(raw_input, dict) else {}
|
||||
personas = _canonical_personas(g.get("personas")) if g.get("status") == "ready" else []
|
||||
group_input = safe_group_input(g.get("input"))
|
||||
summaries.append({
|
||||
"id": g.get("id"),
|
||||
"title": g.get("title", ""),
|
||||
"status": g.get("status", "draft"),
|
||||
"visibility": g.get("visibility", "public"),
|
||||
"is_owned": g.get("is_owned") is True,
|
||||
"channel": group_input.get("channel"),
|
||||
"org_id": g.get("org_id"),
|
||||
"persona_count": len(personas),
|
||||
@@ -478,7 +620,7 @@ def analyze_group(gid: str):
|
||||
if group is None:
|
||||
raise ApiError("group not found", 404)
|
||||
if not isinstance(group, dict):
|
||||
raise ApiError("group state unavailable", 503)
|
||||
raise ApiError("group not found", 404)
|
||||
_authorize_group(group)
|
||||
# A trainee may analyze only their OWN private group; they must never
|
||||
# trigger (re)analysis of an org-shared product.
|
||||
@@ -487,9 +629,21 @@ def analyze_group(gid: str):
|
||||
if request.args.get("append") == "true":
|
||||
raise ApiError("append mode is no longer supported", 400)
|
||||
|
||||
inp = group.get("input", {})
|
||||
raw_input = group.get("input")
|
||||
inp = safe_group_input(raw_input)
|
||||
if isinstance(raw_input, dict):
|
||||
for field, limit in (("description", 4000), ("file_text", 60000)):
|
||||
value = raw_input.get(field)
|
||||
if isinstance(value, str):
|
||||
inp[field] = value[:limit]
|
||||
if not s["llm"]:
|
||||
raise ApiError("LLM not configured", 500)
|
||||
requested_channel = inp.get("channel")
|
||||
persona_channel = (
|
||||
requested_channel
|
||||
if isinstance(requested_channel, str) and requested_channel in PERSONA_CHANNELS
|
||||
else "facebook"
|
||||
)
|
||||
|
||||
from ..services.analyzer import Analyzer
|
||||
from ..services.persona_generator import PersonaGenerator
|
||||
@@ -507,15 +661,29 @@ def analyze_group(gid: str):
|
||||
s["groups"].update(gid, status="failed", error="analysis_failed")
|
||||
raise internal_error("analysis failed", exc)
|
||||
try:
|
||||
personas = PersonaGenerator(s["llm"]).generate(
|
||||
generated_personas = PersonaGenerator(s["llm"]).generate(
|
||||
sales_kit=sales_kit,
|
||||
language=inp.get("language", "th"),
|
||||
channel=inp.get("channel") if inp.get("channel") in PERSONA_CHANNELS else "facebook",
|
||||
channel=persona_channel,
|
||||
)
|
||||
if not isinstance(generated_personas, list) or not generated_personas:
|
||||
raise ValueError("persona generation returned no usable personas")
|
||||
personas = [_validated_persona(persona) for persona in generated_personas]
|
||||
if any(persona is None for persona in personas):
|
||||
raise ValueError("persona generation returned malformed personas")
|
||||
personas = [persona for persona in personas if persona is not None]
|
||||
except Exception as exc:
|
||||
s["groups"].update(gid, status="failed", error="analysis_failed")
|
||||
raise internal_error("analysis failed", exc)
|
||||
|
||||
# Analysis replaces the generated base personas, but preserves accepted
|
||||
# variants that were appended in the previous ready state.
|
||||
current_before_publish = s["groups"].get(gid)
|
||||
personas = _merge_preserved_variants(
|
||||
current_before_publish.get("personas") if isinstance(current_before_publish, dict) else [],
|
||||
personas,
|
||||
)
|
||||
|
||||
from ..services.report import build_report
|
||||
|
||||
try:
|
||||
@@ -526,15 +694,16 @@ def analyze_group(gid: str):
|
||||
# Publish the completed analysis as one atomic record replacement. Readers
|
||||
# do not take the group mutation lock, so status must never become ready
|
||||
# before personas/report/sales_kit are visible together.
|
||||
published = s["groups"].get(gid)
|
||||
published.update(
|
||||
sales_kit=sales_kit,
|
||||
personas=personas,
|
||||
report=report,
|
||||
status="ready",
|
||||
error=None,
|
||||
)
|
||||
s["groups"].groups.replace(gid, published)
|
||||
try:
|
||||
published = s["groups"].publish_analysis(
|
||||
gid,
|
||||
sales_kit=sales_kit,
|
||||
personas=personas,
|
||||
report=report,
|
||||
)
|
||||
except (TypeError, ValueError) as exc:
|
||||
s["groups"].update(gid, status="failed", error="analysis_failed")
|
||||
raise internal_error("analysis failed", exc)
|
||||
serialized = serialize_group(published, current_user())
|
||||
return jsonify({
|
||||
"group": serialized,
|
||||
@@ -547,12 +716,71 @@ def analyze_group(gid: str):
|
||||
@require_auth
|
||||
def get_group(gid: str):
|
||||
s = _stores()
|
||||
group = _get_owned_group(s, gid)
|
||||
# Owners need to reopen draft/failed products to retry analysis. Personas
|
||||
# and chat routes keep the default ready-only gate below.
|
||||
group = _get_owned_group(s, gid, require_ready=False)
|
||||
actor = current_user()
|
||||
|
||||
return jsonify({"group": serialize_group(group, actor)})
|
||||
|
||||
|
||||
@groups_bp.patch("/<gid>")
|
||||
@groups_bp.patch("/<gid>/metadata")
|
||||
@groups_bp.patch("/<gid>/visibility")
|
||||
@require_auth
|
||||
@require_roles("admin")
|
||||
def update_group_metadata(gid: str):
|
||||
"""Safely update group title/visibility without exposing arbitrary fields.
|
||||
|
||||
``/<gid>/visibility`` is a compatibility alias for the frontend client;
|
||||
all three paths intentionally share the same allowlist and authorization.
|
||||
"""
|
||||
s = _stores()
|
||||
group = s["groups"].get_or_none(gid)
|
||||
if group is None:
|
||||
raise ApiError("group not found", 404)
|
||||
if not isinstance(group, dict):
|
||||
raise ApiError("group not found", 404)
|
||||
_authorize_group(group)
|
||||
data = request_json_object()
|
||||
unknown = sorted(set(data) - {"title", "visibility"})
|
||||
if unknown:
|
||||
raise ApiError(f"field '{unknown[0]}' cannot be changed", 400)
|
||||
fields = {}
|
||||
if "title" in data:
|
||||
title = data["title"]
|
||||
if not isinstance(title, str) or not title.strip() or len(title.strip()) > 200:
|
||||
raise ApiError("title must be a non-empty string of at most 200 characters", 400)
|
||||
if contains_control_characters(title):
|
||||
raise ApiError("title contains invalid characters", 400)
|
||||
fields["title"] = title.strip()
|
||||
if "visibility" in data:
|
||||
visibility = data["visibility"]
|
||||
if not isinstance(visibility, str):
|
||||
raise ApiError("visibility must be a string", 400)
|
||||
visibility = visibility.strip()
|
||||
if visibility not in {"public", "hidden", "private", "demo"}:
|
||||
raise ApiError("visibility is invalid", 400)
|
||||
if "owner_user_id" in group:
|
||||
if visibility != "private":
|
||||
raise ApiError("private groups cannot change visibility", 403)
|
||||
elif visibility == "private":
|
||||
raise ApiError("shared groups cannot become private", 403)
|
||||
elif visibility == "demo":
|
||||
if current_user().get("role") != "super_admin":
|
||||
raise ApiError("only super_admin can publish demo groups", 403)
|
||||
if group.get("org_id") != Config.DEMO_ORG_ID:
|
||||
raise ApiError("demo groups must use the demo organization", 403)
|
||||
fields["visibility"] = visibility
|
||||
if not fields:
|
||||
raise ApiError("no editable fields supplied", 400)
|
||||
try:
|
||||
updated = s["groups"].update(gid, **fields)
|
||||
except ValueError as exc:
|
||||
raise internal_error("group update failed", exc, 400)
|
||||
return jsonify({"group": serialize_group(updated, current_user())})
|
||||
|
||||
|
||||
def _report_scalar(value, *, limit: int = 1000) -> str | None:
|
||||
"""Allow only bounded scalar report values; never stringify nested internals."""
|
||||
if isinstance(value, bool):
|
||||
@@ -587,6 +815,8 @@ def group_report(gid: str):
|
||||
"""Return a human-readable report without latent persona recipe fields."""
|
||||
s = _stores()
|
||||
group = _get_owned_group(s, gid)
|
||||
if group.get("status") != "ready":
|
||||
raise ApiError("group not ready", 404)
|
||||
raw_sales_kit = group.get("sales_kit")
|
||||
sales_kit: dict = raw_sales_kit if isinstance(raw_sales_kit, dict) else {}
|
||||
raw_group_input = group.get("input")
|
||||
@@ -651,18 +881,16 @@ def group_report(gid: str):
|
||||
def list_personas(gid: str):
|
||||
s = _stores()
|
||||
group = _get_owned_group(s, gid)
|
||||
if group.get("status") != "ready":
|
||||
raise ApiError("group not ready", 403)
|
||||
actor = current_user()
|
||||
raw_personas = group.get("personas")
|
||||
persona_records = raw_personas if isinstance(raw_personas, list) else []
|
||||
if actor.get("role") == "user":
|
||||
if group.get("status") != "ready":
|
||||
raise ApiError("group not ready", 403)
|
||||
personas = [serialize_persona(p, actor) for p in persona_records if isinstance(p, dict)]
|
||||
elif actor.get("role") == "super_admin":
|
||||
personas = [serialize_persona(p, actor) for p in persona_records if isinstance(p, dict)]
|
||||
else:
|
||||
# admin: see persona but not the secret/process fields (IP protection)
|
||||
personas = [serialize_persona(p, actor) for p in persona_records if isinstance(p, dict)]
|
||||
persona_records = _canonical_personas(group.get("personas")) if group.get("status") == "ready" else []
|
||||
persona_ids = {
|
||||
persona.get("id")
|
||||
for persona in persona_records
|
||||
if isinstance(persona, dict) and isinstance(persona.get("id"), str)
|
||||
}
|
||||
personas = [serialize_persona(p, actor) for p in persona_records]
|
||||
# Attach per-user status (won/lost/not-tried) for EVERY role. The one-shot
|
||||
# rule is per (user, persona): each user may chat a persona once, but the
|
||||
# same persona can be trained by many different users. So my_outcome reflects
|
||||
@@ -682,8 +910,26 @@ def list_personas(gid: str):
|
||||
and (r.get("mode") or "trainee") == "trainee"
|
||||
and r.get("status") == "finished"
|
||||
and r.get("outcome") in {"won", "lost"}
|
||||
and r.get("persona_id") in persona_ids
|
||||
)
|
||||
outcome_by_pid = {r.get("persona_id"): r.get("outcome") for r in mine}
|
||||
from .chat_routes import _authorize_session_context
|
||||
|
||||
authorized_mine = []
|
||||
for session in mine:
|
||||
try:
|
||||
_authorize_session_context(
|
||||
s,
|
||||
session,
|
||||
gid=gid,
|
||||
pid=session.get("persona_id"),
|
||||
required_status="finished",
|
||||
)
|
||||
except ApiError:
|
||||
continue
|
||||
authorized_mine.append(session)
|
||||
outcome_by_pid = {
|
||||
r.get("persona_id"): r.get("outcome") for r in authorized_mine
|
||||
}
|
||||
for p in personas:
|
||||
p["my_outcome"] = outcome_by_pid.get(p.get("id"), "not_tried")
|
||||
return jsonify({"personas": personas, "tiers": ["A", "B", "C"]})
|
||||
@@ -694,13 +940,12 @@ def list_personas(gid: str):
|
||||
def get_persona(gid: str, pid: str):
|
||||
s = _stores()
|
||||
group = _get_owned_group(s, gid)
|
||||
if group.get("status") != "ready":
|
||||
raise ApiError("group not ready", 403)
|
||||
p = s["groups"].get_persona(gid, pid)
|
||||
if not p:
|
||||
raise ApiError("persona not found", 404)
|
||||
actor = current_user()
|
||||
# Trainees may only view personas from ready groups (parity with list_personas).
|
||||
if actor.get("role") == "user" and group.get("status") != "ready":
|
||||
raise ApiError("group not ready", 403)
|
||||
ensure = ensure_persona_shape(p)
|
||||
return jsonify({"persona": serialize_persona(ensure, actor)})
|
||||
|
||||
@@ -711,7 +956,9 @@ def get_persona(gid: str, pid: str):
|
||||
@_group_analysis_lock
|
||||
def update_persona(gid: str, pid: str):
|
||||
s = _stores()
|
||||
_get_owned_group(s, gid)
|
||||
group = _get_owned_group(s, gid)
|
||||
if group.get("status") != "ready":
|
||||
raise ApiError("group not ready", 403)
|
||||
data = request_json_object()
|
||||
actor = current_user()
|
||||
# IP protection: only super_admin may set/alter secret formula fields.
|
||||
@@ -747,7 +994,7 @@ def update_persona(gid: str, pid: str):
|
||||
|
||||
@groups_bp.post("/<gid>/personas/<pid>/variant")
|
||||
@require_auth
|
||||
@_group_analysis_lock
|
||||
@require_roles("user", "admin")
|
||||
def create_persona_variant(gid: str, pid: str):
|
||||
"""Create a NEW persona cloned from an existing one (fresh identity, same core traits).
|
||||
|
||||
@@ -766,7 +1013,15 @@ def create_persona_variant(gid: str, pid: str):
|
||||
if src is None:
|
||||
raise ApiError("persona not found", 404)
|
||||
|
||||
lang = (group.get("input") or {}).get("language", "th")
|
||||
group_input = safe_group_input(group.get("input"))
|
||||
source_snapshot = {
|
||||
"persona": copy.deepcopy(src),
|
||||
"sales_kit": copy.deepcopy(group.get("sales_kit")),
|
||||
"input": copy.deepcopy(group_input),
|
||||
"visibility": group_visibility(group.get("visibility")),
|
||||
"analysis_revision": group.get("analysis_revision", 0),
|
||||
}
|
||||
lang = group_input.get("language", "th")
|
||||
try:
|
||||
from ..services.persona_generator import PersonaGenerator
|
||||
variant = PersonaGenerator(s["llm"]).generate_variant(
|
||||
@@ -780,50 +1035,59 @@ def create_persona_variant(gid: str, pid: str):
|
||||
# Assign a unique id. Trainee-created variants belong to the trainee's private
|
||||
# group; only admin-created variants extend the shared admin pool.
|
||||
import uuid as _uuid
|
||||
try:
|
||||
variant = ensure_persona_shape(variant)
|
||||
validate_persona_traits(variant)
|
||||
except (AttributeError, TypeError, ValueError) as exc:
|
||||
raise internal_error("variant returned invalid persona", exc, 502)
|
||||
variant["id"] = f"persona-{_uuid.uuid4().hex[:10]}"
|
||||
variant["source_persona_id"] = pid
|
||||
actor = current_user()
|
||||
target_group_id = gid
|
||||
if actor.get("role") == "user":
|
||||
actor_org_id = actor.get("org_id")
|
||||
if not is_valid_tenant_id(actor_org_id):
|
||||
raise ApiError("permission denied", 403)
|
||||
try:
|
||||
private_group = s["groups"].get_or_create_private_group(
|
||||
org_id=actor_org_id,
|
||||
owner_user_id=actor["id"],
|
||||
owner_name=actor.get("name", "User"),
|
||||
input_data=group.get("input") or None,
|
||||
sales_kit=group.get("sales_kit") or None,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise internal_error("private group unavailable", exc, 409)
|
||||
target_group_id = private_group["id"]
|
||||
# Re-read while holding the target record lock. The snapshot returned by
|
||||
# get_or_create_private_group may be stale when two trainee requests append
|
||||
# variants concurrently.
|
||||
with s["groups"].record_lock(target_group_id):
|
||||
current_private = s["groups"].get(target_group_id)
|
||||
raw_private_personas = current_private.get("personas")
|
||||
private_personas = (
|
||||
[p for p in raw_private_personas if isinstance(p, dict)]
|
||||
if isinstance(raw_private_personas, list)
|
||||
else []
|
||||
)
|
||||
s["groups"].set_personas(
|
||||
target_group_id, private_personas + [variant]
|
||||
)
|
||||
else:
|
||||
# Admin variants extend the shared group. Serialize and re-read the
|
||||
# append so concurrent admins cannot overwrite one another.
|
||||
with s["groups"].record_lock(gid):
|
||||
current_group = s["groups"].get(gid)
|
||||
raw_current_personas = current_group.get("personas")
|
||||
current_personas = (
|
||||
[p for p in raw_current_personas if isinstance(p, dict)]
|
||||
if isinstance(raw_current_personas, list)
|
||||
else []
|
||||
)
|
||||
# Generation stays outside the lock. Publication re-authorizes and compares
|
||||
# the complete generation source while locked, catching deletion, hiding,
|
||||
# reanalysis, and same-ID trait replacement for both actor paths.
|
||||
with s["groups"].record_lock(gid):
|
||||
current_group = _get_owned_group(s, gid)
|
||||
if not is_ready_group(current_group):
|
||||
raise ApiError("group not ready", 403)
|
||||
raw_current_personas = current_group.get("personas")
|
||||
current_personas = (
|
||||
[p for p in raw_current_personas if isinstance(p, dict)]
|
||||
if isinstance(raw_current_personas, list)
|
||||
else []
|
||||
)
|
||||
current_source = next(
|
||||
(persona for persona in current_personas if persona.get("id") == pid),
|
||||
None,
|
||||
)
|
||||
current_snapshot = {
|
||||
"persona": current_source,
|
||||
"sales_kit": current_group.get("sales_kit"),
|
||||
"input": safe_group_input(current_group.get("input")),
|
||||
"visibility": group_visibility(current_group.get("visibility")),
|
||||
"analysis_revision": current_group.get("analysis_revision", 0),
|
||||
}
|
||||
if current_source is None or current_snapshot != source_snapshot:
|
||||
raise ApiError("source persona changed during variant generation", 409)
|
||||
|
||||
if actor.get("role") == "user":
|
||||
actor_org_id = actor.get("org_id")
|
||||
if not is_valid_tenant_id(actor_org_id):
|
||||
raise ApiError("permission denied", 403)
|
||||
try:
|
||||
private_group = s["groups"].append_private_persona(
|
||||
org_id=actor_org_id,
|
||||
owner_user_id=actor["id"],
|
||||
owner_name=actor.get("name", "User"),
|
||||
persona=variant,
|
||||
input_data=group_input or None,
|
||||
sales_kit=group.get("sales_kit") or None,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise internal_error("private group unavailable", exc, 409)
|
||||
target_group_id = private_group["id"]
|
||||
else:
|
||||
s["groups"].update(gid, personas=current_personas + [variant])
|
||||
full = ensure_persona_shape(variant)
|
||||
|
||||
@@ -845,7 +1109,7 @@ def delete_group(gid: str):
|
||||
# session. Session start also takes this lock, so it cannot publish a new
|
||||
# session after the cascade's snapshot and before group deletion.
|
||||
with s["groups"].record_lock(gid):
|
||||
group = _get_owned_group(s, gid) # also enforces tenant org (super_admin global)
|
||||
group = _get_owned_group(s, gid, require_ready=False)
|
||||
raw_input = group.get("input")
|
||||
upload_names = (
|
||||
[name for name in raw_input.get("files", []) if isinstance(name, str)]
|
||||
|
||||
@@ -8,7 +8,12 @@ from typing import Any, Callable
|
||||
from flask import current_app, g, has_request_context, jsonify, request
|
||||
from werkzeug.exceptions import HTTPException, RequestEntityTooLarge
|
||||
|
||||
from ..auth.users import AuthError, is_valid_tenant_id
|
||||
from ..auth.users import (
|
||||
AuthError,
|
||||
demo_trial_is_active,
|
||||
is_valid_terms_timestamp,
|
||||
is_valid_tenant_id,
|
||||
)
|
||||
from ..config import Config
|
||||
from ..storage.store import StoreError
|
||||
|
||||
@@ -94,6 +99,10 @@ def require_auth(fn: Callable) -> Callable:
|
||||
raise ApiError("invalid account state", 401)
|
||||
if user.get("active") is not True:
|
||||
raise ApiError("account is inactive", 401)
|
||||
if user.get("role") == "demo" and not demo_trial_is_active(user):
|
||||
# Check the persisted trial on every request so a JWT issued before
|
||||
# expiry cannot keep a demo session alive.
|
||||
raise ApiError("demo trial expired", 401)
|
||||
if (
|
||||
not isinstance(user.get("id"), str)
|
||||
or user.get("id") != subject
|
||||
@@ -136,7 +145,18 @@ def require_auth(fn: Callable) -> Callable:
|
||||
# Tenant context: every request carries the actor's org id so downstream guards
|
||||
# can enforce per-org isolation without re-reading the user each time.
|
||||
g.org_id = user_org_id
|
||||
if user["must_setup"] is True and request.endpoint not in {"auth.me", "auth.setup"}:
|
||||
must_setup = user.get("must_setup")
|
||||
accepted_terms = user.get("accepted_terms")
|
||||
if (
|
||||
not isinstance(must_setup, bool)
|
||||
or not isinstance(accepted_terms, bool)
|
||||
or (accepted_terms and not is_valid_terms_timestamp(user.get("accepted_terms_at")))
|
||||
or (not accepted_terms and user.get("accepted_terms_at") is not None)
|
||||
or (must_setup is False and accepted_terms is not True)
|
||||
):
|
||||
raise ApiError("invalid or revoked token", 401)
|
||||
setup_required = must_setup or not accepted_terms
|
||||
if setup_required and request.endpoint not in {"auth.me", "auth.setup"}:
|
||||
raise ApiError("account setup required", 403)
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
|
||||
@@ -2,11 +2,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from typing import Callable, Iterator, cast
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from ..config import Config
|
||||
from ..llm import LLMError
|
||||
from ..services.trainee import MyPersonaStore, analyze_weak_areas
|
||||
from ..storage.store import StoreError
|
||||
from .helpers import (
|
||||
ApiError,
|
||||
current_user,
|
||||
@@ -20,6 +23,42 @@ from .helpers import (
|
||||
me_bp = Blueprint("me", __name__)
|
||||
|
||||
|
||||
def _bounded_records(collection: object, scanned: list[int]) -> list[dict]:
|
||||
limit = Config.ANALYTICS_EXPORT_MAX_SCAN_RECORDS
|
||||
if isinstance(limit, bool) or not isinstance(limit, int) or limit <= 0:
|
||||
raise ApiError("analytics is unavailable", 503)
|
||||
raw_iter = getattr(collection, "iter_all", None)
|
||||
if not callable(raw_iter):
|
||||
raise ApiError("analytics is unavailable", 503)
|
||||
record_iter = cast(Callable[[], Iterator[dict]], raw_iter)
|
||||
records: list[dict] = []
|
||||
try:
|
||||
for record in record_iter():
|
||||
scanned[0] += 1
|
||||
if scanned[0] > limit:
|
||||
raise ApiError("analytics scan limit exceeded", 413)
|
||||
if isinstance(record, dict):
|
||||
records.append(record)
|
||||
except (OSError, StoreError, TypeError, ValueError) as exc:
|
||||
raise ApiError("analytics is unavailable", 503) from exc
|
||||
return records
|
||||
|
||||
|
||||
def _user_sessions(s, actor: dict, scanned: list[int]) -> list[dict]:
|
||||
org_id = actor.get("org_id")
|
||||
user_id = actor.get("id")
|
||||
if not is_valid_tenant_id(org_id) or not isinstance(user_id, str) or not user_id:
|
||||
raise ApiError("permission denied", 403)
|
||||
records = _bounded_records(s["sessions"].sessions, scanned)
|
||||
return sorted(
|
||||
[
|
||||
row for row in records
|
||||
if row.get("user_id") == user_id and row.get("org_id") == org_id
|
||||
],
|
||||
key=lambda row: row.get("created_at", ""),
|
||||
)
|
||||
|
||||
|
||||
def _stores():
|
||||
from flask import current_app
|
||||
|
||||
@@ -31,20 +70,38 @@ def _stores():
|
||||
}
|
||||
|
||||
|
||||
def _authorized_finished_trainee_sessions(s, actor) -> list[dict]:
|
||||
def _reject_demo_persona_management(actor: dict) -> None:
|
||||
# Private personas belong to the trainee surface. Admins and super_admins
|
||||
# retain the shared-product management flow and must not create records that
|
||||
# their group/chat authorization policy cannot later consume.
|
||||
if actor.get("role") != "user":
|
||||
raise ApiError("only trainee accounts can manage private personas", 403)
|
||||
|
||||
|
||||
def _is_legacy_trainee_mode(session: object) -> bool:
|
||||
"""Treat only a missing mode as legacy trainee data."""
|
||||
if not isinstance(session, dict):
|
||||
return False
|
||||
mode = session.get("mode")
|
||||
return mode is None or mode == "trainee"
|
||||
|
||||
|
||||
def _authorized_finished_trainee_sessions(
|
||||
s, actor, scanned: list[int] | None = None
|
||||
) -> list[dict]:
|
||||
"""Return only current, actor-authorized sessions usable as evidence."""
|
||||
org_id = actor.get("org_id")
|
||||
user_id = actor.get("id")
|
||||
if not is_valid_tenant_id(org_id) or not isinstance(user_id, str) or not user_id:
|
||||
raise ApiError("permission denied", 403)
|
||||
sessions = s["sessions"].list_for_user(user_id, org_id=org_id)
|
||||
sessions = _user_sessions(s, actor, scanned if scanned is not None else [0])
|
||||
from .chat_routes import _authorize_session_context
|
||||
|
||||
authorized = []
|
||||
for session in sessions:
|
||||
if (
|
||||
not isinstance(session, dict)
|
||||
or (session.get("mode") or "trainee") != "trainee"
|
||||
or not _is_legacy_trainee_mode(session)
|
||||
or session.get("status") != "finished"
|
||||
or session.get("outcome") not in ("won", "lost")
|
||||
):
|
||||
@@ -67,20 +124,43 @@ def win_lose_board():
|
||||
org_id = actor.get("org_id")
|
||||
if not is_valid_tenant_id(org_id):
|
||||
raise ApiError("permission denied", 403)
|
||||
my_sessions = s["sessions"].list_for_user(uid, org_id=org_id)
|
||||
scanned = [0]
|
||||
my_sessions = _user_sessions(s, actor, scanned)
|
||||
from .chat_routes import _authorize_session_context
|
||||
|
||||
authorized_sessions = []
|
||||
for session in my_sessions:
|
||||
if (
|
||||
not isinstance(session, dict)
|
||||
or not _is_legacy_trainee_mode(session)
|
||||
or session.get("status") not in {"active", "finished"}
|
||||
or (
|
||||
session.get("status") == "finished"
|
||||
and session.get("outcome") not in {"won", "lost"}
|
||||
)
|
||||
):
|
||||
continue
|
||||
try:
|
||||
_authorize_session_context(s, session)
|
||||
except ApiError:
|
||||
continue
|
||||
authorized_sessions.append(session)
|
||||
outcome_by = {
|
||||
(x.get("group_id"), x.get("persona_id")): (
|
||||
"active" if x.get("status") == "active" else x.get("outcome")
|
||||
)
|
||||
for x in my_sessions
|
||||
if isinstance(x, dict)
|
||||
for x in authorized_sessions
|
||||
}
|
||||
|
||||
group_records = _bounded_records(s["groups"].groups, scanned)
|
||||
groups = s["groups"].list_visible_to(
|
||||
role="user", org_id=org_id, user_id=uid
|
||||
role=actor.get("role"), org_id=org_id, user_id=uid,
|
||||
records=group_records,
|
||||
)
|
||||
items = []
|
||||
for g in groups:
|
||||
if not isinstance(g, dict) or g.get("status") != "ready":
|
||||
continue
|
||||
raw_personas = g.get("personas")
|
||||
personas = raw_personas if isinstance(raw_personas, list) else []
|
||||
for p in personas:
|
||||
@@ -136,6 +216,7 @@ def _personal_group(s, actor) -> dict:
|
||||
def my_personas():
|
||||
s = _stores()
|
||||
actor = current_user()
|
||||
_reject_demo_persona_management(actor)
|
||||
group = _personal_group(s, actor)
|
||||
from .group_routes import serialize_group, serialize_persona
|
||||
|
||||
@@ -152,6 +233,7 @@ def my_personas():
|
||||
def generate_persona():
|
||||
s = _stores()
|
||||
actor = current_user()
|
||||
_reject_demo_persona_management(actor)
|
||||
data = request_json_object()
|
||||
mode = data.get("mode", "manual") # "weak-area" | "manual"
|
||||
spec = data.get("spec") or {}
|
||||
|
||||
@@ -2,9 +2,9 @@
|
||||
|
||||
Public signup is permitted ONLY through a verified social identity. The
|
||||
provider token is validated server-side (never trusting the client), and a new
|
||||
user is created in ``OAUTH_DEFAULT_ORG`` (role ``user``, seat-checked). If the
|
||||
provider's verified email already belongs to an active user, that user is
|
||||
logged in (email-match linking) instead.
|
||||
user is created in ``OAUTH_DEFAULT_ORG`` (role ``user``, seat-checked). The
|
||||
provider identity is persisted and is the only key accepted for later login;
|
||||
email-only account linking is deliberately rejected.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -61,20 +61,11 @@ def _unique_username(store, base: str) -> str:
|
||||
|
||||
|
||||
def _ensure_default_org_user(store, provider: str, provider_sub: str, email: str, display_name: str) -> dict:
|
||||
"""Create (or ensure) the default org, then create the user in it.
|
||||
|
||||
The org is created once if missing with ``active=True`` so new signups pass
|
||||
the SaaS tenant gate. Seats are enforced by ``UserStore.create_user``.
|
||||
"""
|
||||
"""Create a bound social user only in a pre-provisioned active org."""
|
||||
org_id = Config.OAUTH_DEFAULT_ORG
|
||||
if Config._is_placeholder(org_id) or not is_valid_tenant_id(org_id):
|
||||
raise AuthError("oauth disabled")
|
||||
org = store.get_org_or_none(org_id)
|
||||
if org is None:
|
||||
with store.orgs.record_lock(org_id):
|
||||
org = store.orgs.get_or_none(org_id)
|
||||
if org is None:
|
||||
org = store.create_org("Public Signups", org_id=org_id)
|
||||
if org is None or org.get("active") is not True:
|
||||
raise AuthError("oauth disabled")
|
||||
|
||||
@@ -91,10 +82,41 @@ def _ensure_default_org_user(store, provider: str, provider_sub: str, email: str
|
||||
name=(display_name or username),
|
||||
role="user",
|
||||
email=email,
|
||||
must_setup=False,
|
||||
# Social signups have no consent field in the provider token exchange.
|
||||
# Route new accounts through /auth/setup before allowing SaaS access.
|
||||
must_setup=True,
|
||||
accepted_terms=False,
|
||||
oauth_identity=(provider, provider_sub),
|
||||
_users_lock_held=True,
|
||||
)
|
||||
|
||||
|
||||
def _bound_user(store, provider: str, provider_sub: str) -> dict | None:
|
||||
"""Resolve one canonical provider subject and reject malformed bindings."""
|
||||
match = None
|
||||
seen: set[tuple[str, str]] = set()
|
||||
for user in store.users.all():
|
||||
identities = user.get("oauth_identities", [])
|
||||
if not isinstance(identities, list):
|
||||
raise AuthError("invalid oauth identity state")
|
||||
for identity in identities:
|
||||
if (
|
||||
not isinstance(identity, dict)
|
||||
or identity.get("provider") not in _PROVIDERS
|
||||
or not isinstance(identity.get("subject"), str)
|
||||
or not identity["subject"].strip()
|
||||
or len(identity["subject"]) > 255
|
||||
):
|
||||
raise AuthError("invalid oauth identity state")
|
||||
identity_key = (identity["provider"], identity["subject"])
|
||||
if identity_key in seen:
|
||||
raise AuthError("duplicate oauth identity")
|
||||
seen.add(identity_key)
|
||||
if identity["provider"] == provider and identity["subject"] == provider_sub:
|
||||
match = user
|
||||
return match
|
||||
|
||||
|
||||
@oauth_bp.post("/oauth")
|
||||
def oauth_exchange():
|
||||
data = request_json_object()
|
||||
@@ -125,11 +147,26 @@ def oauth_exchange():
|
||||
|
||||
store = _store()
|
||||
try:
|
||||
existing = store.by_email(email)
|
||||
if existing is not None and existing.get("active") is True:
|
||||
user = existing
|
||||
else:
|
||||
user = _ensure_default_org_user(store, provider, provider_sub, email, display_name)
|
||||
if (
|
||||
not isinstance(provider_sub, str)
|
||||
or not provider_sub.strip()
|
||||
or len(provider_sub) > 255
|
||||
):
|
||||
raise AuthError("invalid oauth identity")
|
||||
provider_sub = provider_sub.strip()
|
||||
with store.users.collection_lock():
|
||||
user = _bound_user(store, provider, provider_sub)
|
||||
if user is None:
|
||||
if store.by_email(email) is not None:
|
||||
raise AuthError("explicit account linking required")
|
||||
user = _ensure_default_org_user(
|
||||
store, provider, provider_sub, email, display_name
|
||||
)
|
||||
if user.get("active") is not True:
|
||||
raise AuthError("oauth disabled")
|
||||
existing_org = store.get_org_or_none(user.get("org_id"))
|
||||
if existing_org is None or existing_org.get("active") is not True:
|
||||
raise AuthError("oauth disabled")
|
||||
jwt = store.issue_token(user)
|
||||
except AuthError:
|
||||
# Generic to the client; never leak why (disabled/seats/invalid state).
|
||||
@@ -137,10 +174,11 @@ def oauth_exchange():
|
||||
except (OSError, TypeError, ValueError, UnicodeError, OverflowError) as exc:
|
||||
raise internal_error("oauth exchange failed", exc, 503)
|
||||
|
||||
public_user = store.public_user(user)
|
||||
return jsonify(
|
||||
{
|
||||
"token": jwt,
|
||||
"user": store.public_user(user),
|
||||
"must_setup": user.get("must_setup") is True,
|
||||
"user": public_user,
|
||||
"must_setup": public_user["must_setup"],
|
||||
}
|
||||
)
|
||||
|
||||
@@ -9,6 +9,7 @@ from __future__ import annotations
|
||||
import datetime
|
||||
import math
|
||||
import re
|
||||
from contextlib import nullcontext
|
||||
from pathlib import Path
|
||||
from typing import Any, TypeGuard
|
||||
|
||||
@@ -40,9 +41,97 @@ PUBLIC_USER_FIELDS = (
|
||||
"active",
|
||||
"accepted_terms",
|
||||
"accepted_terms_at",
|
||||
"is_demo",
|
||||
"trial_started_at",
|
||||
"trial_expires_at",
|
||||
)
|
||||
|
||||
|
||||
def utc_now() -> datetime.datetime:
|
||||
return datetime.datetime.now(datetime.timezone.utc)
|
||||
|
||||
|
||||
def is_valid_terms_timestamp(value: object) -> bool:
|
||||
"""Require a timezone-aware ISO timestamp for accepted consent."""
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return False
|
||||
try:
|
||||
parsed = datetime.datetime.fromisoformat(value)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return parsed.tzinfo is not None and parsed.utcoffset() is not None
|
||||
|
||||
|
||||
def _parse_utc_timestamp(value: object) -> datetime.datetime | None:
|
||||
"""Parse persisted ISO timestamps without accepting naive/invalid values."""
|
||||
if not isinstance(value, str) or not value.strip():
|
||||
return None
|
||||
try:
|
||||
parsed = datetime.datetime.fromisoformat(value.replace("Z", "+00:00"))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if parsed.tzinfo is None:
|
||||
return None
|
||||
return parsed.astimezone(datetime.timezone.utc)
|
||||
|
||||
|
||||
def is_demo_account(user: dict[str, Any]) -> bool:
|
||||
"""Treat the role as authoritative for legacy records missing demo fields."""
|
||||
return isinstance(user, dict) and user.get("role") == "demo"
|
||||
|
||||
|
||||
def _demo_trial_state(
|
||||
user: dict[str, Any],
|
||||
) -> tuple[datetime.datetime | None, datetime.datetime | None, bool]:
|
||||
"""Return ``(started, expires, valid)`` for a persisted demo trial.
|
||||
|
||||
Before first login both timestamps are absent. Once either timestamp is
|
||||
present, both must be timezone-aware, ordered, and exactly the fixed trial
|
||||
duration. Invalid legacy state fails closed instead of granting access.
|
||||
"""
|
||||
if not is_demo_account(user):
|
||||
return None, None, True
|
||||
started_raw = user.get("trial_started_at")
|
||||
expires_raw = user.get("trial_expires_at")
|
||||
started_present = started_raw not in (None, "")
|
||||
expires_present = expires_raw not in (None, "")
|
||||
if not started_present and not expires_present:
|
||||
return None, None, True
|
||||
if started_present != expires_present:
|
||||
return None, None, False
|
||||
started = _parse_utc_timestamp(started_raw)
|
||||
expires = _parse_utc_timestamp(expires_raw)
|
||||
if started is None or expires is None:
|
||||
return None, None, False
|
||||
if expires <= started or expires - started != datetime.timedelta(days=Config.DEMO_TRIAL_DAYS):
|
||||
return None, None, False
|
||||
return started, expires, True
|
||||
|
||||
|
||||
def demo_trial_is_active(user: dict[str, Any], *, now: datetime.datetime | None = None) -> bool:
|
||||
"""Return whether a demo account is inside its fixed UTC trial window."""
|
||||
if not is_demo_account(user):
|
||||
return True
|
||||
_started, expires, valid = _demo_trial_state(user)
|
||||
if not valid:
|
||||
return False
|
||||
if expires is None:
|
||||
# A demo account before its first successful login has no expiry yet.
|
||||
return True
|
||||
current = now or utc_now()
|
||||
if current.tzinfo is None:
|
||||
current = current.replace(tzinfo=datetime.timezone.utc)
|
||||
current = current.astimezone(datetime.timezone.utc)
|
||||
return current < expires
|
||||
|
||||
|
||||
def demo_trial_expiry(user: dict[str, Any]) -> datetime.datetime | None:
|
||||
if not is_demo_account(user):
|
||||
return None
|
||||
_started, expires, valid = _demo_trial_state(user)
|
||||
return expires if valid else None
|
||||
|
||||
|
||||
class AuthError(Exception):
|
||||
pass
|
||||
|
||||
@@ -102,16 +191,27 @@ class UserStore:
|
||||
self.orgs = JsonStore(data_dir / "orgs")
|
||||
|
||||
# ── org ────────────────────────────────────────────────────────────
|
||||
def create_org(self, name: str, *, org_id: str | None = None) -> dict[str, Any]:
|
||||
def create_org(
|
||||
self,
|
||||
name: str,
|
||||
*,
|
||||
org_id: str | None = None,
|
||||
seats: int = 5,
|
||||
plan: str = "trial",
|
||||
) -> dict[str, Any]:
|
||||
oid = new_id("org") if org_id is None else org_id
|
||||
if not is_valid_tenant_id(oid):
|
||||
raise AuthError("invalid organization id")
|
||||
if isinstance(seats, bool) or not isinstance(seats, int) or seats < 1:
|
||||
raise AuthError("invalid organization seats")
|
||||
if plan not in ("trial", "paid", "enterprise"):
|
||||
raise AuthError("invalid organization plan")
|
||||
return self.orgs.create(
|
||||
{
|
||||
"name": name,
|
||||
"id": oid,
|
||||
"plan": "trial",
|
||||
"seats": 5,
|
||||
"plan": plan,
|
||||
"seats": seats,
|
||||
"active": True,
|
||||
"created_at": __import__("time").strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
},
|
||||
@@ -127,6 +227,24 @@ class UserStore:
|
||||
return None
|
||||
return self.orgs.get_or_none(org_id)
|
||||
|
||||
def ensure_demo_org(self) -> dict[str, Any]:
|
||||
"""Create the configured demo tenant once, then fail closed if inactive."""
|
||||
org_id = Config.DEMO_ORG_ID
|
||||
if not is_valid_tenant_id(org_id):
|
||||
raise AuthError("demo organization is invalid")
|
||||
with self.orgs.record_lock(org_id):
|
||||
org = self.orgs.get_or_none(org_id)
|
||||
if org is None:
|
||||
org = self.create_org(
|
||||
Config.DEMO_ORG_NAME,
|
||||
org_id=org_id,
|
||||
seats=Config.DEMO_ORG_SEATS,
|
||||
plan="trial",
|
||||
)
|
||||
if org.get("active") is not True:
|
||||
raise AuthError("demo organization is inactive")
|
||||
return org
|
||||
|
||||
# ── users ──────────────────────────────────────────────────────────
|
||||
@staticmethod
|
||||
def _norm(username: str) -> str:
|
||||
@@ -193,6 +311,7 @@ class UserStore:
|
||||
or not is_valid_tenant_id(org_id)
|
||||
or not isinstance(role, str)
|
||||
or role not in Config.ROLES
|
||||
or (role == "demo" and org_id != Config.DEMO_ORG_ID)
|
||||
):
|
||||
raise AuthError("invalid account state")
|
||||
persisted_email = user.get("email")
|
||||
@@ -238,13 +357,24 @@ class UserStore:
|
||||
role: str = "user",
|
||||
email: str | None = None,
|
||||
must_setup: bool = False,
|
||||
allow_demo: bool = False,
|
||||
accepted_terms: bool = False,
|
||||
oauth_identity: tuple[str, str] | None = None,
|
||||
_users_lock_held: bool = False,
|
||||
) -> dict[str, Any]:
|
||||
if not is_valid_tenant_id(org_id):
|
||||
raise AuthError("invalid organization id")
|
||||
if not isinstance(role, str) or role not in Config.ROLES:
|
||||
raise AuthError(f"invalid role: {role}")
|
||||
if role == "demo":
|
||||
if not allow_demo:
|
||||
raise AuthError("demo accounts require dedicated provisioning")
|
||||
if org_id != Config.DEMO_ORG_ID:
|
||||
raise AuthError("demo accounts must use the demo organization")
|
||||
if not isinstance(must_setup, bool):
|
||||
raise AuthError("must_setup must be boolean")
|
||||
if not isinstance(accepted_terms, bool):
|
||||
raise AuthError("accepted_terms must be boolean")
|
||||
username = self._norm(username)
|
||||
if not username or not password:
|
||||
raise AuthError("username and password are required")
|
||||
@@ -254,6 +384,17 @@ class UserStore:
|
||||
if not isinstance(name, str):
|
||||
raise AuthError("name must be a string")
|
||||
email = self.validate_email(email)
|
||||
if oauth_identity is not None:
|
||||
if (
|
||||
not isinstance(oauth_identity, tuple)
|
||||
or len(oauth_identity) != 2
|
||||
or oauth_identity[0] not in ("google", "facebook")
|
||||
or not isinstance(oauth_identity[1], str)
|
||||
or not oauth_identity[1].strip()
|
||||
or len(oauth_identity[1]) > 255
|
||||
):
|
||||
raise AuthError("invalid oauth identity")
|
||||
oauth_identity = (oauth_identity[0], oauth_identity[1].strip())
|
||||
# Username/email uniqueness and seat capacity are collection-wide
|
||||
# invariants. Keep their checks with the create so setup/profile writes
|
||||
# cannot reserve the same email concurrently in another worker.
|
||||
@@ -261,19 +402,23 @@ class UserStore:
|
||||
# otherwise an admin can deactivate/shrink a tenant between this check
|
||||
# and the user write.
|
||||
try:
|
||||
with self.orgs.record_lock(org_id):
|
||||
org = self.orgs.get_or_none(org_id)
|
||||
if org is None:
|
||||
raise AuthError("organization not found")
|
||||
if org.get("active") is not True:
|
||||
raise AuthError("organization is inactive")
|
||||
raw_seats = org.get("seats", 5)
|
||||
if type(raw_seats) is not int or raw_seats < 0:
|
||||
raise AuthError("invalid organization state")
|
||||
seats = raw_seats
|
||||
if seats <= 0:
|
||||
raise AuthError("organization has no available seats")
|
||||
with self.users.collection_lock():
|
||||
# Keep the lock order users -> organization for every account
|
||||
# creation path. Registration relies on this outer collection lock
|
||||
# to make the first-user role decision atomic with the write.
|
||||
users_lock = nullcontext() if _users_lock_held else self.users.collection_lock()
|
||||
with users_lock:
|
||||
with self.orgs.record_lock(org_id):
|
||||
org = self.orgs.get_or_none(org_id)
|
||||
if org is None:
|
||||
raise AuthError("organization not found")
|
||||
if org.get("active") is not True:
|
||||
raise AuthError("organization is inactive")
|
||||
raw_seats = org.get("seats", 5)
|
||||
if type(raw_seats) is not int or raw_seats < 0:
|
||||
raise AuthError("invalid organization state")
|
||||
seats = raw_seats
|
||||
if seats <= 0:
|
||||
raise AuthError("organization has no available seats")
|
||||
existing = [u for u in self.users.all() if u.get("org_id") == org_id]
|
||||
if len(existing) >= seats:
|
||||
raise AuthError("organization seat limit reached")
|
||||
@@ -291,16 +436,49 @@ class UserStore:
|
||||
"password_hash": generate_password_hash(password),
|
||||
"role": role,
|
||||
"must_setup": must_setup,
|
||||
"accepted_terms": accepted_terms,
|
||||
"accepted_terms_at": utc_now().isoformat() if accepted_terms else None,
|
||||
"auth_version": 0,
|
||||
"created_at": datetime.datetime.now(datetime.timezone.utc).isoformat(),
|
||||
"active": True,
|
||||
}
|
||||
if oauth_identity is not None:
|
||||
user["oauth_identities"] = [
|
||||
{"provider": oauth_identity[0], "subject": oauth_identity[1]}
|
||||
]
|
||||
if role == "demo":
|
||||
user.update(
|
||||
is_demo=True,
|
||||
trial_started_at=None,
|
||||
trial_expires_at=None,
|
||||
)
|
||||
return self.users.create(user, key=username)
|
||||
except AuthError:
|
||||
raise
|
||||
except (OSError, StoreError, TypeError, UnicodeError, ValueError, OverflowError) as exc:
|
||||
raise AuthError("account storage unavailable") from exc
|
||||
|
||||
def create_demo_user(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
username: str,
|
||||
password: str,
|
||||
name: str = "Demo User",
|
||||
) -> dict[str, Any]:
|
||||
"""Create the only supported kind of demo user."""
|
||||
return self.create_user(
|
||||
org_id=Config.DEMO_ORG_ID,
|
||||
username=username,
|
||||
password=password,
|
||||
name=name,
|
||||
role="demo",
|
||||
email=email,
|
||||
must_setup=True,
|
||||
accepted_terms=False,
|
||||
allow_demo=True,
|
||||
)
|
||||
|
||||
def get_user(self, username: str) -> dict[str, Any]:
|
||||
return self.users.get(self._norm(username))
|
||||
|
||||
@@ -404,6 +582,8 @@ class UserStore:
|
||||
if role is not _UNSET:
|
||||
if not isinstance(role, str) or role not in Config.ROLES:
|
||||
raise AuthError("invalid role")
|
||||
if role == "demo":
|
||||
raise AuthError("demo accounts require dedicated provisioning")
|
||||
fields["role"] = role
|
||||
if active is not _UNSET:
|
||||
if not isinstance(active, bool):
|
||||
@@ -415,6 +595,10 @@ class UserStore:
|
||||
current = self.users.get_or_none(username)
|
||||
if current is None:
|
||||
raise AuthError("user not found")
|
||||
if current.get("role") == "demo" and (
|
||||
role is not _UNSET or active is not _UNSET
|
||||
):
|
||||
raise AuthError("manage demo accounts through the dedicated endpoint")
|
||||
if normalized_email is not None:
|
||||
existing = self.by_email(normalized_email)
|
||||
if existing and existing["id"] != username:
|
||||
@@ -432,7 +616,8 @@ class UserStore:
|
||||
new_password: str,
|
||||
*,
|
||||
accepted_terms: object,
|
||||
accepted_terms_at: str,
|
||||
accepted_terms_at: str | None = None,
|
||||
server_generated_at: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Complete setup for a user that is still marked ``must_setup``.
|
||||
|
||||
@@ -447,30 +632,92 @@ class UserStore:
|
||||
if email is None: # defensive: required email validation above
|
||||
raise AuthError("invalid email")
|
||||
password_hash = generate_password_hash(new_password)
|
||||
consent_timestamp = (
|
||||
server_generated_at
|
||||
if is_valid_terms_timestamp(server_generated_at)
|
||||
else utc_now().isoformat()
|
||||
)
|
||||
with self.users.collection_lock():
|
||||
with self.users.record_lock(username):
|
||||
current = self.users.get_or_none(username)
|
||||
if current is None:
|
||||
raise AuthError("user not found")
|
||||
if current.get("must_setup") is not True:
|
||||
if current.get("must_setup") is False and current.get("accepted_terms") is True:
|
||||
raise SetupAlreadyCompletedError("setup already completed")
|
||||
existing = self.by_email(email)
|
||||
if existing and existing["id"] != username:
|
||||
raise AuthError("a user with this email already exists")
|
||||
updated = self.users.update_if(
|
||||
username,
|
||||
lambda latest: latest.get("must_setup") is True,
|
||||
lambda latest: (
|
||||
latest.get("must_setup") is not False
|
||||
or latest.get("accepted_terms") is not True
|
||||
),
|
||||
email=email,
|
||||
password_hash=password_hash,
|
||||
must_setup=False,
|
||||
accepted_terms=True,
|
||||
accepted_terms_at=accepted_terms_at,
|
||||
accepted_terms_at=consent_timestamp,
|
||||
auth_version=self._next_auth_version(current),
|
||||
)
|
||||
if updated is None:
|
||||
raise SetupAlreadyCompletedError("setup already completed")
|
||||
return updated
|
||||
|
||||
def start_demo_trial(
|
||||
self,
|
||||
username: str,
|
||||
*,
|
||||
now: datetime.datetime | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Atomically start a demo trial on the first successful login only."""
|
||||
username = self._norm(username)
|
||||
current_time = (now or utc_now()).astimezone(datetime.timezone.utc)
|
||||
with self.users.record_lock(username):
|
||||
current = self.users.get_or_none(username)
|
||||
if (
|
||||
current is None
|
||||
or not is_demo_account(current)
|
||||
or current.get("org_id") != Config.DEMO_ORG_ID
|
||||
or current.get("active") is not True
|
||||
):
|
||||
raise AuthError("demo account not found")
|
||||
started, expires, valid = _demo_trial_state(current)
|
||||
if not valid:
|
||||
raise AuthError("invalid demo trial state")
|
||||
if expires is not None:
|
||||
if current_time >= expires:
|
||||
raise AuthError("demo trial expired")
|
||||
return current
|
||||
if started is not None:
|
||||
# A started trial without an expiry is malformed and must not be
|
||||
# silently repaired into a longer trial.
|
||||
raise AuthError("invalid demo trial state")
|
||||
expiry = current_time + datetime.timedelta(days=Config.DEMO_TRIAL_DAYS)
|
||||
return self.users.update(
|
||||
username,
|
||||
is_demo=True,
|
||||
trial_started_at=current_time.isoformat(),
|
||||
trial_expires_at=expiry.isoformat(),
|
||||
)
|
||||
|
||||
def revoke_demo(self, username: str) -> dict[str, Any]:
|
||||
"""Deactivate a demo account and invalidate every issued auth token."""
|
||||
username = self._norm(username)
|
||||
with self.users.record_lock(username):
|
||||
current = self.users.get_or_none(username)
|
||||
if (
|
||||
current is None
|
||||
or not is_demo_account(current)
|
||||
or current.get("org_id") != Config.DEMO_ORG_ID
|
||||
):
|
||||
raise AuthError("demo account not found")
|
||||
return self.users.update(
|
||||
username,
|
||||
active=False,
|
||||
auth_version=self._next_auth_version(current),
|
||||
)
|
||||
|
||||
# ── auth ───────────────────────────────────────────────────────────
|
||||
def verify(self, ident: str, password: str) -> dict[str, Any]:
|
||||
# Resolve by EITHER username OR email (the login form doesn't distinguish,
|
||||
@@ -489,6 +736,10 @@ class UserStore:
|
||||
raise AuthError("invalid credentials")
|
||||
if not isinstance(user.get("must_setup"), bool):
|
||||
raise AuthError("invalid credentials")
|
||||
if user.get("accepted_terms") is True and not is_valid_terms_timestamp(
|
||||
user.get("accepted_terms_at")
|
||||
):
|
||||
raise AuthError("invalid credentials")
|
||||
# SaaS gate: orphaned or inactive organizations cannot sign in.
|
||||
if not isinstance(user.get("role"), str) or user.get("role") not in Config.ROLES:
|
||||
raise AuthError("invalid credentials")
|
||||
@@ -507,22 +758,45 @@ class UserStore:
|
||||
self._token_identity(user)
|
||||
except AuthError as exc:
|
||||
raise AuthError("invalid credentials") from exc
|
||||
if is_demo_account(user):
|
||||
_started, expires, valid = _demo_trial_state(user)
|
||||
if not valid:
|
||||
raise AuthError("invalid credentials")
|
||||
if expires is not None and utc_now() >= expires:
|
||||
raise AuthError("invalid credentials")
|
||||
if not self._password_matches(user.get("password_hash"), password):
|
||||
raise AuthError("invalid credentials")
|
||||
return user
|
||||
|
||||
def issue_token(self, user: dict[str, Any]) -> str:
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
now = utc_now()
|
||||
subject, org_id, role = self._token_identity(user)
|
||||
if user.get("active") is not True:
|
||||
raise AuthError("invalid account state")
|
||||
expires_hours = Config.JWT_EXPIRES_HOURS
|
||||
if (
|
||||
isinstance(expires_hours, bool)
|
||||
or not isinstance(expires_hours, int)
|
||||
or expires_hours < Config.JWT_MIN_EXPIRES_HOURS
|
||||
or expires_hours > Config.JWT_MAX_EXPIRES_HOURS
|
||||
):
|
||||
raise AuthError("invalid account state")
|
||||
expires_at = now + datetime.timedelta(hours=expires_hours)
|
||||
if is_demo_account(user):
|
||||
trial_expires = demo_trial_expiry(user)
|
||||
if trial_expires is None or now >= trial_expires:
|
||||
raise AuthError("demo trial expired")
|
||||
# A demo JWT may never outlive the fixed UTC trial window.
|
||||
expires_at = min(expires_at, trial_expires)
|
||||
if expires_at <= now:
|
||||
raise AuthError("invalid account state")
|
||||
payload = {
|
||||
"sub": subject,
|
||||
"org_id": org_id,
|
||||
"role": role,
|
||||
"auth_version": self._auth_version(user),
|
||||
"iat": now,
|
||||
"exp": now + datetime.timedelta(hours=Config.JWT_EXPIRES_HOURS),
|
||||
"exp": expires_at,
|
||||
}
|
||||
return jwt.encode(payload, Config.SECRET_KEY, algorithm=Config.JWT_ALGO)
|
||||
|
||||
@@ -557,7 +831,11 @@ class UserStore:
|
||||
or isinstance(payload.get("exp"), bool)
|
||||
or not isinstance(payload.get("exp"), (int, float))
|
||||
or not math.isfinite(payload["exp"])
|
||||
or payload["exp"] <= 0
|
||||
or payload["iat"] <= 0
|
||||
or payload["iat"] >= payload["exp"]
|
||||
or payload["exp"] - payload["iat"]
|
||||
> Config.JWT_EXPIRES_HOURS * 60 * 60
|
||||
):
|
||||
raise AuthError("invalid or expired token")
|
||||
return payload
|
||||
@@ -567,8 +845,19 @@ class UserStore:
|
||||
raise AuthError("invalid or expired token") from exc
|
||||
|
||||
def public_user(self, user: dict[str, Any]) -> dict[str, Any]:
|
||||
return {
|
||||
public = {
|
||||
field: user[field]
|
||||
for field in PUBLIC_USER_FIELDS
|
||||
if field in user
|
||||
}
|
||||
# Legacy records may omit either consent/setup marker. Normalize those
|
||||
# omissions to the safe public state so clients also redirect to setup.
|
||||
public["must_setup"] = user.get("must_setup") is not False
|
||||
public["accepted_terms"] = user.get("accepted_terms") is True
|
||||
if is_demo_account(user):
|
||||
# Legacy demo records may predate these fields; expose the role-safe
|
||||
# state without ever exposing a password or a recovery mechanism.
|
||||
public["is_demo"] = True
|
||||
public.setdefault("trial_started_at", user.get("trial_started_at"))
|
||||
public.setdefault("trial_expires_at", user.get("trial_expires_at"))
|
||||
return public
|
||||
|
||||
@@ -17,6 +17,36 @@ def _get_bool(name: str, default: bool = False) -> bool:
|
||||
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def _get_int(
|
||||
name: str,
|
||||
default: int,
|
||||
*,
|
||||
minimum: int | None = None,
|
||||
maximum: int | None = None,
|
||||
) -> int:
|
||||
"""Read an optional integer without breaking import on bad environment input."""
|
||||
try:
|
||||
value = int(os.environ.get(name, str(default)))
|
||||
except (TypeError, ValueError):
|
||||
value = default
|
||||
if minimum is not None and value < minimum:
|
||||
return default
|
||||
if maximum is not None and value > maximum:
|
||||
return default
|
||||
return value
|
||||
|
||||
|
||||
def _get_float(name: str, default: float, *, minimum: float | None = None) -> float:
|
||||
"""Read an optional float with a safe fallback for malformed deployment config."""
|
||||
try:
|
||||
value = float(os.environ.get(name, str(default)))
|
||||
except (TypeError, ValueError):
|
||||
value = default
|
||||
if minimum is not None and value < minimum:
|
||||
return default
|
||||
return value
|
||||
|
||||
|
||||
def resolve_llm() -> tuple[str, str, str, str | None]:
|
||||
"""Return (base_url, model, api_key, provider_name)."""
|
||||
provider = os.environ.get("LLM_PROVIDER", "").strip()
|
||||
@@ -52,9 +82,39 @@ class Config:
|
||||
BOOTSTRAP_ADMIN_PASSWORD = os.environ.get("BOOTSTRAP_ADMIN_PASSWORD", "").strip()
|
||||
SECRET_KEY = os.environ.get("JWT_SECRET", "").strip()
|
||||
JWT_ALGO = "HS256"
|
||||
JWT_EXPIRES_HOURS = int(os.environ.get("JWT_EXPIRES_HOURS", "24"))
|
||||
JWT_MIN_EXPIRES_HOURS = 1
|
||||
JWT_MAX_EXPIRES_HOURS = 24 * 30
|
||||
JWT_EXPIRES_HOURS = _get_int(
|
||||
"JWT_EXPIRES_HOURS",
|
||||
24,
|
||||
minimum=JWT_MIN_EXPIRES_HOURS,
|
||||
maximum=JWT_MAX_EXPIRES_HOURS,
|
||||
)
|
||||
MIN_PASSWORD_LENGTH = 12
|
||||
|
||||
# Demo accounts are isolated in their own tenant and get a fixed UTC trial
|
||||
# that starts on first successful password login. Keep the defaults safe for
|
||||
# local development; production should set the tenant id explicitly.
|
||||
DEMO_ORG_ID = os.environ.get("DEMO_ORG_ID", "org-demo").strip() or "org-demo"
|
||||
DEMO_ORG_NAME = os.environ.get("DEMO_ORG_NAME", "Sales Trainer Demo").strip() or "Sales Trainer Demo"
|
||||
DEMO_ORG_SEATS = _get_int("DEMO_ORG_SEATS", 10000, minimum=1)
|
||||
DEMO_TRIAL_DAYS = 30
|
||||
|
||||
# Demo credential delivery is fail-closed and opt-in. Tests/local installs
|
||||
# remain usable when neither SES nor the webhook is configured.
|
||||
AWS_REGION = os.environ.get("AWS_REGION", "").strip()
|
||||
SES_FROM_EMAIL = os.environ.get("SES_FROM_EMAIL", "").strip()
|
||||
SES_REPLY_TO = os.environ.get("SES_REPLY_TO", "").strip()
|
||||
DEMO_WEBHOOK_URL = os.environ.get("DEMO_WEBHOOK_URL", "").strip()
|
||||
DEMO_WEBHOOK_SECRET = os.environ.get("DEMO_WEBHOOK_SECRET", "").strip()
|
||||
DEMO_WEBHOOK_TIMEOUT_SECONDS = _get_float(
|
||||
"DEMO_WEBHOOK_TIMEOUT_SECONDS", 5.0, minimum=0.1
|
||||
)
|
||||
DEMO_WEBHOOK_MAX_RETRIES = _get_int("DEMO_WEBHOOK_MAX_RETRIES", 3, minimum=1)
|
||||
DEMO_WEBHOOK_RETRY_BACKOFF_SECONDS = _get_float(
|
||||
"DEMO_WEBHOOK_RETRY_BACKOFF_SECONDS", 0.25, minimum=0.0
|
||||
)
|
||||
|
||||
DATA_DIR = Path(
|
||||
os.environ.get("DATA_DIR", str(_BACKEND_DIR / "data"))
|
||||
).resolve()
|
||||
@@ -109,7 +169,7 @@ class Config:
|
||||
"facebook": ("OAUTH_FACEBOOK_APP_ID", "OAUTH_FACEBOOK_APP_SECRET"),
|
||||
}
|
||||
|
||||
ROLES = ("super_admin", "admin", "user")
|
||||
ROLES = ("super_admin", "admin", "user", "demo")
|
||||
|
||||
@classmethod
|
||||
def oauth_provider_enabled(cls, provider: str) -> bool:
|
||||
@@ -133,16 +193,33 @@ class Config:
|
||||
or normalized.startswith("your_")
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _has_secret_diversity(value: str) -> bool:
|
||||
"""Reject trivially repeated secrets that satisfy length checks only."""
|
||||
if not isinstance(value, str) or len(set(value)) < 4:
|
||||
return False
|
||||
for period in range(1, len(value) // 2 + 1):
|
||||
if len(value) % period == 0 and value == value[:period] * (len(value) // period):
|
||||
return False
|
||||
return True
|
||||
|
||||
@classmethod
|
||||
def validate_runtime_security(cls, *, require_bootstrap: bool = False) -> None:
|
||||
"""Fail closed for production secrets and first-run initialization."""
|
||||
secure_runtime = cls.APP_ENV not in {"development", "test"} or not cls.FLASK_DEBUG
|
||||
if secure_runtime and (cls._is_placeholder(cls.SECRET_KEY) or len(cls.SECRET_KEY) < 32):
|
||||
if secure_runtime and (
|
||||
cls._is_placeholder(cls.SECRET_KEY)
|
||||
or len(cls.SECRET_KEY) < 32
|
||||
or not cls._has_secret_diversity(cls.SECRET_KEY)
|
||||
):
|
||||
raise RuntimeError("JWT_SECRET must be configured with at least 32 characters")
|
||||
if require_bootstrap:
|
||||
if cls._is_placeholder(cls.BOOTSTRAP_ADMIN_PASSWORD):
|
||||
raise RuntimeError("BOOTSTRAP_ADMIN_PASSWORD is required to initialize the first admin")
|
||||
if len(cls.BOOTSTRAP_ADMIN_PASSWORD) < cls.MIN_PASSWORD_LENGTH:
|
||||
if (
|
||||
len(cls.BOOTSTRAP_ADMIN_PASSWORD) < cls.MIN_PASSWORD_LENGTH
|
||||
or not cls._has_secret_diversity(cls.BOOTSTRAP_ADMIN_PASSWORD)
|
||||
):
|
||||
raise RuntimeError(
|
||||
"BOOTSTRAP_ADMIN_PASSWORD must be at least "
|
||||
f"{cls.MIN_PASSWORD_LENGTH} characters"
|
||||
@@ -150,5 +227,5 @@ class Config:
|
||||
|
||||
@classmethod
|
||||
def ensure_dirs(cls) -> None:
|
||||
for name in ("users", "orgs", "groups", "sessions"):
|
||||
for name in ("users", "orgs", "groups", "sessions", "demo_deliveries"):
|
||||
(cls.DATA_DIR / name).mkdir(parents=True, exist_ok=True)
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
"""Flask application factory."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from flask import Flask, abort
|
||||
@@ -9,28 +10,33 @@ from flask_cors import CORS
|
||||
from .auth.users import AuthError, UserStore
|
||||
from .config import Config
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def bootstrap_admin(users: UserStore) -> None:
|
||||
"""Ensure a default org + super-admin exists on first run (no self-registration)."""
|
||||
# Use a stable org record lock even before the record exists. Every worker
|
||||
# therefore rechecks the store after the first worker finishes initialization.
|
||||
with users.orgs.record_lock("org-default"):
|
||||
Config.validate_runtime_security(require_bootstrap=not bool(users.users.all()))
|
||||
if users.users.all():
|
||||
return
|
||||
org = users.orgs.get_or_none("org-default")
|
||||
if org is None:
|
||||
org = users.create_org("Default Organization", org_id="org-default")
|
||||
if users.get_user_or_none("admin") is None:
|
||||
users.create_user(
|
||||
org_id=org["id"],
|
||||
username="admin",
|
||||
password=Config.BOOTSTRAP_ADMIN_PASSWORD,
|
||||
name="Super Admin",
|
||||
role="super_admin",
|
||||
must_setup=True,
|
||||
)
|
||||
print("[bootstrap] created initial super-admin; first-time setup is required")
|
||||
with users.users.collection_lock():
|
||||
with users.orgs.record_lock("org-default"):
|
||||
Config.validate_runtime_security(require_bootstrap=not bool(users.users.all()))
|
||||
if users.users.all():
|
||||
return
|
||||
org = users.orgs.get_or_none("org-default")
|
||||
if org is None:
|
||||
org = users.create_org("Default Organization", org_id="org-default")
|
||||
if users.get_user_or_none("admin") is None:
|
||||
users.create_user(
|
||||
org_id=org["id"],
|
||||
username="admin",
|
||||
password=Config.BOOTSTRAP_ADMIN_PASSWORD,
|
||||
name="Super Admin",
|
||||
role="super_admin",
|
||||
must_setup=True,
|
||||
accepted_terms=False,
|
||||
_users_lock_held=True,
|
||||
)
|
||||
logger.info("created initial super-admin; first-time setup is required")
|
||||
|
||||
|
||||
def create_app() -> Flask:
|
||||
@@ -63,6 +69,7 @@ def create_app() -> Flask:
|
||||
from .auth.users import UserStore
|
||||
from .llm import LLMClient
|
||||
from .llm import LLMError
|
||||
from .services.demo_delivery import DemoCredentialDelivery
|
||||
from .services.groups import GroupStore
|
||||
from .services.sessions import SessionStore
|
||||
from .services.trainee import MyPersonaStore
|
||||
@@ -71,6 +78,7 @@ def create_app() -> Flask:
|
||||
app.extensions["group_store"] = GroupStore(Config.DATA_DIR)
|
||||
app.extensions["session_store"] = SessionStore(Config.DATA_DIR)
|
||||
app.extensions["my_persona_store"] = MyPersonaStore(Config.DATA_DIR)
|
||||
app.extensions["demo_delivery"] = DemoCredentialDelivery(Config.DATA_DIR)
|
||||
try:
|
||||
app.extensions["llm"] = LLMClient()
|
||||
except LLMError as exc:
|
||||
|
||||
266
backend/app/services/demo_delivery.py
Normal file
266
backend/app/services/demo_delivery.py
Normal file
@@ -0,0 +1,266 @@
|
||||
"""One-time demo credential delivery via SES and an authenticated webhook.
|
||||
|
||||
The temporary password is kept only in the request process. It is never written
|
||||
into the JSON stores, audit log, application logs, or a retrieval endpoint.
|
||||
Both integrations are opt-in so local tests and development work without AWS
|
||||
credentials or an outbound network.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
import json
|
||||
import logging
|
||||
import secrets
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.parse import urlparse
|
||||
from urllib.request import HTTPRedirectHandler, Request, build_opener
|
||||
|
||||
from ..config import Config
|
||||
from ..storage.store import JsonStore
|
||||
|
||||
LOGGER = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class _HttpsOnlyRedirectHandler(HTTPRedirectHandler):
|
||||
"""Permit only same-origin HTTPS redirects without URL credentials."""
|
||||
|
||||
@staticmethod
|
||||
def _origin(parsed):
|
||||
if parsed.scheme != "https" or not parsed.hostname:
|
||||
return None
|
||||
try:
|
||||
port = parsed.port or 443
|
||||
except ValueError:
|
||||
return None
|
||||
return parsed.hostname.lower(), port
|
||||
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
from urllib.parse import urljoin
|
||||
|
||||
source = urlparse(req.full_url)
|
||||
target = urlparse(urljoin(req.full_url, newurl))
|
||||
if (
|
||||
target.username is not None
|
||||
or target.password is not None
|
||||
or target.fragment
|
||||
or self._origin(source) is None
|
||||
or self._origin(target) is None
|
||||
or self._origin(source) != self._origin(target)
|
||||
):
|
||||
raise URLError("insecure or cross-origin webhook redirect refused")
|
||||
return super().redirect_request(req, fp, code, msg, headers, newurl)
|
||||
|
||||
|
||||
def _open_https(request: Request, *, timeout: float):
|
||||
"""Open a webhook request without allowing an HTTPS-to-HTTP downgrade."""
|
||||
return build_opener(_HttpsOnlyRedirectHandler()).open(request, timeout=timeout)
|
||||
|
||||
|
||||
class DemoCredentialDelivery:
|
||||
"""Deliver demo credentials with explicit, inspectable per-channel status."""
|
||||
|
||||
def __init__(self, data_dir: Path) -> None:
|
||||
self.events = JsonStore(data_dir / "demo_deliveries")
|
||||
|
||||
@staticmethod
|
||||
def generate_password() -> str:
|
||||
# URL-safe output is easy to paste and comfortably exceeds the policy.
|
||||
return secrets.token_urlsafe(24)
|
||||
|
||||
@staticmethod
|
||||
def generate_username(existing: Callable[[str], Any]) -> str:
|
||||
for _ in range(20):
|
||||
candidate = f"demo-{secrets.token_hex(6)}"
|
||||
if existing(candidate) is None:
|
||||
return candidate
|
||||
raise RuntimeError("could not allocate demo username")
|
||||
|
||||
def deliver(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
username: str,
|
||||
temporary_password: str,
|
||||
trial_expires_at: str | None,
|
||||
) -> dict[str, Any]:
|
||||
event_id = f"demo-account-{secrets.token_urlsafe(18)}"
|
||||
created_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime())
|
||||
email_status = self._send_email(
|
||||
email=email,
|
||||
username=username,
|
||||
temporary_password=temporary_password,
|
||||
trial_expires_at=trial_expires_at,
|
||||
)
|
||||
webhook_status = self._send_webhook(
|
||||
event_id=event_id,
|
||||
created_at=created_at,
|
||||
email=email,
|
||||
username=username,
|
||||
temporary_password=temporary_password,
|
||||
trial_expires_at=trial_expires_at,
|
||||
)
|
||||
self._record_event(
|
||||
event_id,
|
||||
{
|
||||
"id": event_id,
|
||||
"event_type": "demo_account.created",
|
||||
"created_at": created_at,
|
||||
"username": username,
|
||||
"email": email,
|
||||
"email_status": email_status,
|
||||
"webhook_status": webhook_status,
|
||||
},
|
||||
)
|
||||
return {
|
||||
"event_id": event_id,
|
||||
"email": email_status,
|
||||
"webhook": webhook_status,
|
||||
}
|
||||
|
||||
def _send_email(
|
||||
self,
|
||||
*,
|
||||
email: str,
|
||||
username: str,
|
||||
temporary_password: str,
|
||||
trial_expires_at: str | None,
|
||||
) -> dict[str, Any]:
|
||||
if not Config.AWS_REGION or not Config.SES_FROM_EMAIL:
|
||||
return {"status": "skipped", "reason": "not_configured", "attempts": 0}
|
||||
try:
|
||||
import boto3 # type: ignore[import-not-found]
|
||||
|
||||
client = boto3.client("ses", region_name=Config.AWS_REGION)
|
||||
reply_to = [Config.SES_REPLY_TO] if Config.SES_REPLY_TO else []
|
||||
expiry_line = trial_expires_at or "after first successful login"
|
||||
client.send_email(
|
||||
Source=Config.SES_FROM_EMAIL,
|
||||
Destination={"ToAddresses": [email]},
|
||||
ReplyToAddresses=reply_to,
|
||||
Message={
|
||||
"Subject": {"Data": "Your Sales Trainer demo access", "Charset": "UTF-8"},
|
||||
"Body": {
|
||||
"Text": {
|
||||
"Data": (
|
||||
"Your Sales Trainer demo account is ready.\n\n"
|
||||
f"Username: {username}\n"
|
||||
f"Temporary password: {temporary_password}\n\n"
|
||||
"Change the password and accept the Terms/Privacy notice on first login.\n"
|
||||
f"Trial expiry: {expiry_line}\n"
|
||||
),
|
||||
"Charset": "UTF-8",
|
||||
}
|
||||
},
|
||||
},
|
||||
)
|
||||
return {"status": "sent", "provider": "ses", "attempts": 1}
|
||||
except Exception as exc: # provider failures must not roll back account creation
|
||||
LOGGER.warning("demo email delivery failed (error_type=%s)", type(exc).__name__)
|
||||
return {
|
||||
"status": "failed",
|
||||
"provider": "ses",
|
||||
"reason": "provider_error",
|
||||
"attempts": 1,
|
||||
}
|
||||
|
||||
def _send_webhook(
|
||||
self,
|
||||
*,
|
||||
event_id: str,
|
||||
created_at: str,
|
||||
email: str,
|
||||
username: str,
|
||||
temporary_password: str,
|
||||
trial_expires_at: str | None,
|
||||
) -> dict[str, Any]:
|
||||
url = Config.DEMO_WEBHOOK_URL
|
||||
secret = Config.DEMO_WEBHOOK_SECRET
|
||||
if not url or not secret:
|
||||
return {"status": "skipped", "reason": "not_configured", "attempts": 0}
|
||||
parsed = urlparse(url)
|
||||
if (
|
||||
parsed.scheme != "https"
|
||||
or not parsed.hostname
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.fragment
|
||||
):
|
||||
return {"status": "failed", "reason": "https_required", "attempts": 0}
|
||||
|
||||
timestamp = str(int(time.time()))
|
||||
payload = {
|
||||
"event_id": event_id,
|
||||
"event_type": "demo_account.created",
|
||||
"created_at": created_at,
|
||||
"email": email,
|
||||
"username": username,
|
||||
"temporary_password": temporary_password,
|
||||
"trial_expires_at": trial_expires_at,
|
||||
}
|
||||
body = json.dumps(payload, ensure_ascii=False, separators=(",", ":"), sort_keys=True).encode("utf-8")
|
||||
signature = hmac.new(
|
||||
secret.encode("utf-8"),
|
||||
timestamp.encode("ascii") + b"." + body,
|
||||
hashlib.sha256,
|
||||
).hexdigest()
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": str(len(body)),
|
||||
"User-Agent": "Sales-Trainer-Demo-Delivery/1",
|
||||
"X-Sales-Trainer-Event-Id": event_id,
|
||||
"X-Sales-Trainer-Timestamp": timestamp,
|
||||
"X-Sales-Trainer-Signature": f"sha256={signature}",
|
||||
"Idempotency-Key": event_id,
|
||||
}
|
||||
try:
|
||||
max_attempts = max(1, min(int(Config.DEMO_WEBHOOK_MAX_RETRIES), 5))
|
||||
except (TypeError, ValueError):
|
||||
max_attempts = 3
|
||||
try:
|
||||
timeout = max(0.1, min(float(Config.DEMO_WEBHOOK_TIMEOUT_SECONDS), 30.0))
|
||||
except (TypeError, ValueError):
|
||||
timeout = 5.0
|
||||
try:
|
||||
backoff = max(0.0, min(float(Config.DEMO_WEBHOOK_RETRY_BACKOFF_SECONDS), 5.0))
|
||||
except (TypeError, ValueError):
|
||||
backoff = 0.25
|
||||
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
reason = "provider_error"
|
||||
try:
|
||||
request = Request(url, data=body, headers=headers, method="POST")
|
||||
with _open_https(request, timeout=timeout) as response:
|
||||
status = int(getattr(response, "status", response.getcode()))
|
||||
if 200 <= status < 300:
|
||||
return {"status": "sent", "provider": "webhook", "attempts": attempt}
|
||||
reason = f"http_{status}"
|
||||
except HTTPError as exc:
|
||||
reason = f"http_{exc.code}"
|
||||
except (URLError, OSError, TimeoutError):
|
||||
reason = "network_error"
|
||||
except Exception as exc:
|
||||
LOGGER.warning("demo webhook delivery failed (error_type=%s)", type(exc).__name__)
|
||||
reason = "provider_error"
|
||||
if attempt < max_attempts and backoff:
|
||||
time.sleep(backoff * attempt)
|
||||
|
||||
LOGGER.warning("demo webhook delivery exhausted bounded retries (reason=%s)", reason)
|
||||
return {
|
||||
"status": "failed",
|
||||
"provider": "webhook",
|
||||
"reason": reason,
|
||||
"attempts": max_attempts,
|
||||
}
|
||||
|
||||
def _record_event(self, event_id: str, record: dict[str, Any]) -> None:
|
||||
# Persist delivery metadata for operational inspection, never payload or
|
||||
# credentials. A write failure should not turn a successful delivery into
|
||||
# a failed account creation.
|
||||
try:
|
||||
self.events.create(record, key=event_id)
|
||||
except Exception as exc:
|
||||
LOGGER.warning("demo delivery audit write failed (error_type=%s)", type(exc).__name__)
|
||||
@@ -12,17 +12,168 @@ from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from ..auth.users import is_valid_tenant_id
|
||||
from ..config import Config
|
||||
from ..storage.store import JsonStore, new_id
|
||||
from .store import ensure_persona_shape, revealable_view, validate_persona_traits
|
||||
|
||||
DEFAULT_TIERS = ["A", "B", "C"]
|
||||
PERSONAS_PER_TIER = 5
|
||||
GROUP_VISIBILITIES = frozenset({"public", "hidden", "private", "demo"})
|
||||
GROUP_STATUSES = frozenset({"draft", "analyzing", "ready", "failed"})
|
||||
GROUP_STATUS_TRANSITIONS = {
|
||||
"draft": frozenset({"draft", "analyzing", "ready", "failed"}),
|
||||
"analyzing": frozenset({"analyzing", "ready", "failed"}),
|
||||
"ready": frozenset({"ready", "analyzing", "draft"}),
|
||||
"failed": frozenset({"failed", "analyzing", "draft"}),
|
||||
}
|
||||
GROUP_INPUT_FIELDS = ("product", "segment", "channel", "language")
|
||||
|
||||
|
||||
def group_visibility(value: object) -> str | None:
|
||||
"""Return only an exact persisted visibility value; malformed is invalid."""
|
||||
return value if isinstance(value, str) and value in GROUP_VISIBILITIES else None
|
||||
|
||||
|
||||
def is_valid_owner_visibility(group: object) -> bool:
|
||||
"""Return whether the persisted owner/visibility pair is fail-closed.
|
||||
|
||||
An owner marker is an authorization boundary, not presentation metadata:
|
||||
it must always identify a non-empty user and the record must be private.
|
||||
Conversely, private records without an owner are never treated as shared.
|
||||
"""
|
||||
if not isinstance(group, dict):
|
||||
return False
|
||||
visibility = group_visibility(group.get("visibility"))
|
||||
if "owner_user_id" in group:
|
||||
owner = group.get("owner_user_id")
|
||||
return (
|
||||
isinstance(owner, str)
|
||||
and bool(owner.strip())
|
||||
and owner == owner.strip()
|
||||
and visibility == "private"
|
||||
)
|
||||
return visibility in {"public", "hidden", "demo"}
|
||||
|
||||
|
||||
def is_canonical_private_owner(
|
||||
group: object,
|
||||
*,
|
||||
user_id: object,
|
||||
org_id: object,
|
||||
) -> bool:
|
||||
"""Require owner identity and tenant identity to match a private record."""
|
||||
return (
|
||||
isinstance(group, dict)
|
||||
and is_valid_owner_visibility(group)
|
||||
and "owner_user_id" in group
|
||||
and isinstance(user_id, str)
|
||||
and bool(user_id)
|
||||
and group.get("owner_user_id") == user_id
|
||||
and is_valid_tenant_id(org_id)
|
||||
and is_valid_tenant_id(group.get("org_id"))
|
||||
and group.get("org_id") == org_id
|
||||
)
|
||||
|
||||
|
||||
def safe_group_input(value: object) -> dict[str, str]:
|
||||
"""Return only bounded string input fields safe for API summaries."""
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
return {
|
||||
field: item[:1000]
|
||||
for field in GROUP_INPUT_FIELDS
|
||||
if isinstance(item := value.get(field), str)
|
||||
}
|
||||
|
||||
|
||||
def _canonical_personas(value: object) -> list[dict[str, Any]]:
|
||||
"""Keep only persisted persona rows that have a stable public id."""
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
return [
|
||||
persona
|
||||
for persona in value
|
||||
if isinstance(persona, dict)
|
||||
and isinstance(persona.get("id"), str)
|
||||
and bool(persona["id"].strip())
|
||||
]
|
||||
|
||||
|
||||
def _now() -> str:
|
||||
return datetime.datetime.now(datetime.timezone.utc).isoformat()
|
||||
|
||||
|
||||
def _normalize_personas(value: object) -> list[dict[str, Any]]:
|
||||
"""Validate persona rows before defaults can hide malformed persisted input."""
|
||||
if not isinstance(value, list) or not value:
|
||||
raise ValueError("ready group requires personas")
|
||||
normalized: list[dict[str, Any]] = []
|
||||
persona_ids: set[str] = set()
|
||||
for persona in value:
|
||||
if (
|
||||
not isinstance(persona, dict)
|
||||
or not isinstance(persona.get("id"), str)
|
||||
or not persona["id"].strip()
|
||||
):
|
||||
raise ValueError("persona id is invalid")
|
||||
shaped = ensure_persona_shape(persona)
|
||||
try:
|
||||
validate_persona_traits(shaped)
|
||||
except (AttributeError, TypeError, ValueError) as exc:
|
||||
raise ValueError("persona traits are invalid") from exc
|
||||
shaped["id"] = shaped["id"].strip()
|
||||
if shaped["id"] in persona_ids:
|
||||
raise ValueError("persona id must be unique")
|
||||
persona_ids.add(shaped["id"])
|
||||
normalized.append(shaped)
|
||||
return normalized
|
||||
|
||||
|
||||
def validated_personas(value: object) -> list[dict[str, Any]]:
|
||||
"""Return only a complete, valid persona collection for read paths."""
|
||||
try:
|
||||
return _normalize_personas(value)
|
||||
except (TypeError, ValueError):
|
||||
return []
|
||||
|
||||
|
||||
def is_ready_group(group: object) -> bool:
|
||||
"""Return whether a persisted group satisfies the complete ready contract."""
|
||||
if not isinstance(group, dict) or group.get("status") != "ready":
|
||||
return False
|
||||
try:
|
||||
_validate_ready_components(
|
||||
sales_kit=group.get("sales_kit"),
|
||||
report=group.get("report"),
|
||||
personas=group.get("personas"),
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def is_listable_group_state(group: object) -> bool:
|
||||
"""Reject malformed lifecycle records while preserving valid recovery states."""
|
||||
if not isinstance(group, dict) or group.get("status") not in GROUP_STATUSES:
|
||||
return False
|
||||
return group.get("status") != "ready" or is_ready_group(group)
|
||||
|
||||
|
||||
def _validate_ready_components(
|
||||
*, sales_kit: object, report: object, personas: object
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Return normalized personas only when the complete ready contract is valid."""
|
||||
normalized = _normalize_personas(personas)
|
||||
if (
|
||||
not isinstance(sales_kit, dict)
|
||||
or not sales_kit
|
||||
or not isinstance(report, dict)
|
||||
or not report
|
||||
):
|
||||
raise ValueError("ready group requires non-empty sales_kit and report")
|
||||
return normalized
|
||||
|
||||
|
||||
class GroupStore:
|
||||
def __init__(self, data_dir: Path) -> None:
|
||||
self.groups = JsonStore(data_dir / "groups")
|
||||
@@ -42,9 +193,36 @@ class GroupStore:
|
||||
visibility: str = "public",
|
||||
input_data: dict[str, Any] | None = None,
|
||||
sales_kit: dict[str, Any] | None = None,
|
||||
report: dict[str, Any] | None = None,
|
||||
personas: list[dict[str, Any]] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
if visibility not in ("public", "hidden", "private"):
|
||||
if status not in GROUP_STATUSES:
|
||||
raise ValueError("group status is invalid")
|
||||
if group_visibility(visibility) is None:
|
||||
raise ValueError("group visibility is invalid")
|
||||
if visibility == "demo" and org_id != Config.DEMO_ORG_ID:
|
||||
raise ValueError("demo groups must use the demo organization")
|
||||
if owner_user_id is None and visibility == "private":
|
||||
raise ValueError("private groups require an owner")
|
||||
if owner_user_id is not None and (
|
||||
not isinstance(owner_user_id, str)
|
||||
or not owner_user_id.strip()
|
||||
or owner_user_id != owner_user_id.strip()
|
||||
):
|
||||
raise ValueError("private owner is invalid")
|
||||
if owner_user_id is not None and visibility == "demo":
|
||||
raise ValueError("private groups cannot be demo-visible")
|
||||
normalized_personas = (
|
||||
[ensure_persona_shape(persona) for persona in personas if isinstance(persona, dict)]
|
||||
if isinstance(personas, list)
|
||||
else []
|
||||
)
|
||||
if status == "ready":
|
||||
normalized_personas = _validate_ready_components(
|
||||
sales_kit=sales_kit,
|
||||
report=report,
|
||||
personas=personas,
|
||||
)
|
||||
gid = new_id("group")
|
||||
group = {
|
||||
"id": gid,
|
||||
@@ -55,10 +233,11 @@ class GroupStore:
|
||||
"visibility": visibility, # public | hidden (shared) | private (owner-only)
|
||||
"created_at": _now(),
|
||||
"updated_at": _now(),
|
||||
"analysis_revision": 0,
|
||||
"input": dict(input_data) if isinstance(input_data, dict) else {},
|
||||
"sales_kit": sales_kit if isinstance(sales_kit, dict) else None,
|
||||
"personas": [], # full persona dicts
|
||||
"report": None,
|
||||
"personas": normalized_personas, # full persona dicts
|
||||
"report": report if isinstance(report, dict) else None,
|
||||
"error": None,
|
||||
}
|
||||
if owner_user_id is not None:
|
||||
@@ -73,8 +252,97 @@ class GroupStore:
|
||||
return self.groups.get_or_none(gid)
|
||||
|
||||
def update(self, gid: str, **fields: Any) -> dict[str, Any]:
|
||||
fields.setdefault("updated_at", _now())
|
||||
return self.groups.update(gid, **fields)
|
||||
# Every lifecycle/ownership mutation participates in the same cooperative
|
||||
# record transaction used by the route-level analysis lock. Without this,
|
||||
# a caller can change ownership/status between a service snapshot and its
|
||||
# revalidation even when both code paths appear to use record_lock().
|
||||
with self.record_lock(gid):
|
||||
current = self.get(gid)
|
||||
current_status = current.get("status")
|
||||
if current_status not in GROUP_STATUSES:
|
||||
raise ValueError("persisted group status is invalid")
|
||||
requested_status = fields.get("status", current_status)
|
||||
if requested_status not in GROUP_STATUSES:
|
||||
raise ValueError("group status is invalid")
|
||||
if requested_status not in GROUP_STATUS_TRANSITIONS[current_status]:
|
||||
raise ValueError(
|
||||
f"group status transition {current_status!r} -> {requested_status!r} is invalid"
|
||||
)
|
||||
if "owner_user_id" in fields:
|
||||
owner_user_id = fields["owner_user_id"]
|
||||
if (
|
||||
not isinstance(owner_user_id, str)
|
||||
or not owner_user_id.strip()
|
||||
or owner_user_id != owner_user_id.strip()
|
||||
):
|
||||
raise ValueError("private owner is invalid")
|
||||
# Adding an owner marker converts a shared record into a private one;
|
||||
# never leave the two authorization fields inconsistent.
|
||||
fields.setdefault("visibility", "private")
|
||||
visibility = fields.get("visibility", current.get("visibility"))
|
||||
if group_visibility(visibility) is None:
|
||||
raise ValueError("group visibility is invalid")
|
||||
org_id = fields.get("org_id", current.get("org_id"))
|
||||
owner_present = "owner_user_id" in current or "owner_user_id" in fields
|
||||
effective_owner = fields.get("owner_user_id", current.get("owner_user_id"))
|
||||
if owner_present and (
|
||||
not isinstance(effective_owner, str)
|
||||
or not effective_owner.strip()
|
||||
or effective_owner != effective_owner.strip()
|
||||
):
|
||||
raise ValueError("private groups require a valid owner")
|
||||
if visibility == "demo" and org_id != Config.DEMO_ORG_ID:
|
||||
raise ValueError("demo groups must use the demo organization")
|
||||
if owner_present and visibility != "private":
|
||||
raise ValueError("private groups cannot change visibility")
|
||||
if visibility == "private" and not owner_present:
|
||||
raise ValueError("private groups require a valid owner")
|
||||
effective_status = requested_status
|
||||
raw_personas = fields.get("personas", current.get("personas"))
|
||||
if effective_status == "ready":
|
||||
fields["personas"] = _validate_ready_components(
|
||||
sales_kit=fields.get("sales_kit", current.get("sales_kit")),
|
||||
report=fields.get("report", current.get("report")),
|
||||
personas=raw_personas,
|
||||
)
|
||||
fields.setdefault("updated_at", _now())
|
||||
return self.groups.update(gid, **fields)
|
||||
|
||||
def publish_analysis(
|
||||
self,
|
||||
gid: str,
|
||||
*,
|
||||
sales_kit: object,
|
||||
personas: object,
|
||||
report: object,
|
||||
) -> dict[str, Any]:
|
||||
"""Atomically validate and publish a completed analysis."""
|
||||
normalized = _validate_ready_components(
|
||||
sales_kit=sales_kit,
|
||||
report=report,
|
||||
personas=personas,
|
||||
)
|
||||
with self.record_lock(gid):
|
||||
current = self.get(gid)
|
||||
if current.get("status") != "analyzing":
|
||||
raise ValueError("group status must be analyzing before publication")
|
||||
published = {
|
||||
**current,
|
||||
"sales_kit": sales_kit,
|
||||
"personas": normalized,
|
||||
"report": report,
|
||||
"status": "ready",
|
||||
"error": None,
|
||||
"updated_at": _now(),
|
||||
"analysis_revision": (
|
||||
current.get("analysis_revision", 0) + 1
|
||||
if isinstance(current.get("analysis_revision", 0), int)
|
||||
and not isinstance(current.get("analysis_revision", 0), bool)
|
||||
and current.get("analysis_revision", 0) >= 0
|
||||
else 1
|
||||
),
|
||||
}
|
||||
return self.groups.replace(gid, published)
|
||||
|
||||
def list_for_org(self, org_id: str | None = None) -> list[dict[str, Any]]:
|
||||
if not is_valid_tenant_id(org_id):
|
||||
@@ -88,6 +356,8 @@ class GroupStore:
|
||||
role: str,
|
||||
org_id: str | None = None,
|
||||
user_id: str | None = None,
|
||||
actor_org_id: str | None = None,
|
||||
records: list[dict[str, Any]] | None = None,
|
||||
) -> list[dict[str, Any]]:
|
||||
"""List groups a role/user can see through a role-safe view.
|
||||
|
||||
@@ -95,69 +365,131 @@ class GroupStore:
|
||||
and revealable persona fields. Keeping this policy here prevents a new
|
||||
route from accidentally turning a raw group index into an IDOR/data leak.
|
||||
"""
|
||||
groups_all = self.groups.all() if records is None else records
|
||||
if role == "super_admin":
|
||||
groups = [g for g in self.groups.all() if isinstance(g, dict)]
|
||||
return groups if org_id is None else [g for g in groups if g.get("org_id") == org_id]
|
||||
shared = [
|
||||
g
|
||||
for g in groups_all
|
||||
if (
|
||||
isinstance(g, dict)
|
||||
and is_listable_group_state(g)
|
||||
and is_valid_owner_visibility(g)
|
||||
and "owner_user_id" not in g
|
||||
)
|
||||
]
|
||||
if org_id is not None:
|
||||
shared = [g for g in shared if g.get("org_id") == org_id]
|
||||
owned = [
|
||||
self._user_visible_group(g, is_owned=True)
|
||||
for g in groups_all
|
||||
if (
|
||||
isinstance(g, dict)
|
||||
and is_listable_group_state(g)
|
||||
and is_canonical_private_owner(
|
||||
g,
|
||||
user_id=user_id,
|
||||
org_id=actor_org_id if actor_org_id is not None else org_id,
|
||||
)
|
||||
and (org_id is None or g.get("org_id") == org_id)
|
||||
)
|
||||
]
|
||||
return shared + owned
|
||||
if not is_valid_tenant_id(org_id):
|
||||
return []
|
||||
groups = [g for g in self.groups.all() if isinstance(g, dict)]
|
||||
groups = [g for g in groups_all if is_listable_group_state(g)]
|
||||
groups = [g for g in groups if g.get("org_id") == org_id]
|
||||
visibility_of = lambda g: (g.get("visibility") or "public") if isinstance(g.get("visibility"), str) else "public"
|
||||
if role == "user":
|
||||
if not isinstance(user_id, str) or not user_id:
|
||||
return []
|
||||
# Own ready private groups + org shared groups that are public & ready.
|
||||
# Own private groups remain visible in every lifecycle state so the
|
||||
# owner can recover from a failed analysis. Shared groups stay
|
||||
# public-and-ready only. Malformed visibility is never public.
|
||||
groups = [
|
||||
g
|
||||
for g in groups
|
||||
if (
|
||||
g.get("status") == "ready"
|
||||
and (
|
||||
# private: only the owner's own
|
||||
("owner_user_id" in g and g.get("owner_user_id") == user_id)
|
||||
# shared: public (not hidden) and not private
|
||||
or (
|
||||
"owner_user_id" not in g
|
||||
and visibility_of(g) != "hidden"
|
||||
)
|
||||
)
|
||||
is_valid_owner_visibility(g)
|
||||
and "owner_user_id" in g
|
||||
and g.get("owner_user_id") == user_id
|
||||
and group_visibility(g.get("visibility")) == "private"
|
||||
)
|
||||
or (
|
||||
is_valid_owner_visibility(g)
|
||||
and "owner_user_id" not in g
|
||||
and is_ready_group(g)
|
||||
and group_visibility(g.get("visibility")) == "public"
|
||||
)
|
||||
]
|
||||
return [self._user_visible_group(g) for g in groups]
|
||||
return [
|
||||
self._user_visible_group(
|
||||
g,
|
||||
is_owned=g.get("owner_user_id") == user_id,
|
||||
)
|
||||
for g in groups
|
||||
]
|
||||
if role == "demo":
|
||||
if org_id != Config.DEMO_ORG_ID or not isinstance(user_id, str) or not user_id:
|
||||
return []
|
||||
groups = [
|
||||
g
|
||||
for g in groups
|
||||
if (
|
||||
is_ready_group(g)
|
||||
and group_visibility(g.get("visibility")) == "demo"
|
||||
and "owner_user_id" not in g
|
||||
)
|
||||
]
|
||||
return [self._user_visible_group(g, is_owned=False) for g in groups]
|
||||
if role == "admin":
|
||||
# A tenant admin may inspect shared group summaries (incl. hidden/draft),
|
||||
# never a trainee's private group. An explicit owner marker is private
|
||||
# even when malformed.
|
||||
return [g for g in groups if "owner_user_id" not in g]
|
||||
# even when malformed; malformed visibility is not a shared summary.
|
||||
shared = [
|
||||
g
|
||||
for g in groups
|
||||
if (
|
||||
is_valid_owner_visibility(g)
|
||||
and "owner_user_id" not in g
|
||||
and group_visibility(g.get("visibility")) in {"public", "hidden"}
|
||||
)
|
||||
]
|
||||
owned = [
|
||||
self._user_visible_group(g, is_owned=True)
|
||||
for g in groups
|
||||
if (
|
||||
isinstance(user_id, str)
|
||||
and user_id
|
||||
and is_valid_owner_visibility(g)
|
||||
and g.get("owner_user_id") == user_id
|
||||
)
|
||||
]
|
||||
return shared + owned
|
||||
return []
|
||||
|
||||
@staticmethod
|
||||
def _user_visible_group(group: dict[str, Any]) -> dict[str, Any]:
|
||||
def _user_visible_group(
|
||||
group: dict[str, Any],
|
||||
*,
|
||||
is_owned: bool,
|
||||
) -> dict[str, Any]:
|
||||
"""Return the closed group envelope allowed to a trainee."""
|
||||
raw_input = group.get("input")
|
||||
source = raw_input if isinstance(raw_input, dict) else {}
|
||||
raw_personas = group.get("personas")
|
||||
personas = raw_personas if isinstance(raw_personas, list) else []
|
||||
source = safe_group_input(group.get("input"))
|
||||
status = group.get("status", "draft")
|
||||
personas = validated_personas(group.get("personas")) if status == "ready" else []
|
||||
return {
|
||||
"id": group.get("id"),
|
||||
"org_id": group.get("org_id"),
|
||||
"owner_user_id": group.get("owner_user_id"),
|
||||
"visibility": group.get("visibility", "public"),
|
||||
"is_owned": is_owned,
|
||||
"title": group.get("title", ""),
|
||||
"status": status,
|
||||
"created_at": group.get("created_at"),
|
||||
"updated_at": group.get("updated_at"),
|
||||
"input": {
|
||||
key: source[key]
|
||||
for key in ("product", "segment", "channel", "language")
|
||||
if key in source
|
||||
},
|
||||
"input": source,
|
||||
"sales_kit": None,
|
||||
"personas": [
|
||||
revealable_view(ensure_persona_shape(persona))
|
||||
for persona in personas
|
||||
if isinstance(persona, dict)
|
||||
],
|
||||
"report": None,
|
||||
"error": "analysis_failed" if status == "failed" and group.get("error") else None,
|
||||
@@ -172,42 +504,222 @@ class GroupStore:
|
||||
input_data: dict[str, Any] | None = None,
|
||||
sales_kit: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Return the one private, ready group owned by a trainee.
|
||||
"""Return the one private group owned by a trainee.
|
||||
|
||||
The lookup and create are one invariant. Keep both inside the
|
||||
collection lock so concurrent web workers cannot allocate duplicate
|
||||
private groups for the same ``(org_id, owner_user_id)`` pair.
|
||||
private groups for the same ``(org_id, owner_user_id)`` pair. Candidate
|
||||
records are re-read under their record lock after the collection snapshot
|
||||
so ownership/status changes cannot make this return stale authorization
|
||||
state.
|
||||
"""
|
||||
if not is_valid_tenant_id(org_id):
|
||||
raise ValueError("organization scope is invalid")
|
||||
if not isinstance(owner_user_id, str) or not owner_user_id.strip():
|
||||
if (
|
||||
not isinstance(owner_user_id, str)
|
||||
or not owner_user_id.strip()
|
||||
or owner_user_id != owner_user_id.strip()
|
||||
):
|
||||
raise ValueError("private owner is invalid")
|
||||
with self.groups.collection_lock():
|
||||
for group in self.list_for_org(org_id=org_id):
|
||||
if group.get("owner_user_id") == owner_user_id:
|
||||
if group.get("status") != "ready":
|
||||
for _ in range(3):
|
||||
candidate_id = None
|
||||
found_candidate = False
|
||||
with self.groups.collection_lock():
|
||||
for group in self.list_for_org(org_id=org_id):
|
||||
if group.get("owner_user_id") == owner_user_id:
|
||||
found_candidate = True
|
||||
candidate_id = group.get("id")
|
||||
break
|
||||
if not found_candidate:
|
||||
# An empty private group is a recoverable draft, never a
|
||||
# ready/publication state. The first persona is published by
|
||||
# set_personas() or append_private_persona().
|
||||
return self.create(
|
||||
org_id=org_id,
|
||||
creator_id=owner_user_id,
|
||||
title=f"{owner_name or 'User'}'s private personas",
|
||||
status="draft",
|
||||
owner_user_id=owner_user_id,
|
||||
input_data=input_data or {"channel": "social", "language": "th"},
|
||||
sales_kit=sales_kit or {
|
||||
"productName": "personal practice",
|
||||
"valueProps": [],
|
||||
"features": [],
|
||||
},
|
||||
)
|
||||
|
||||
if (
|
||||
not isinstance(candidate_id, str)
|
||||
or not candidate_id.strip()
|
||||
or candidate_id != candidate_id.strip()
|
||||
):
|
||||
raise ValueError("private group state is invalid")
|
||||
with self.record_lock(candidate_id):
|
||||
current = self.get_or_none(candidate_id)
|
||||
if current is None:
|
||||
continue
|
||||
if (
|
||||
current.get("org_id") != org_id
|
||||
or current.get("owner_user_id") != owner_user_id
|
||||
):
|
||||
continue
|
||||
if not is_valid_owner_visibility(current):
|
||||
raise ValueError("private group state is invalid")
|
||||
if current.get("status") not in {"draft", "analyzing", "ready", "failed"}:
|
||||
raise ValueError("private group state is invalid")
|
||||
status = current.get("status")
|
||||
if status == "ready":
|
||||
try:
|
||||
normalized = _validate_ready_components(
|
||||
sales_kit=current.get("sales_kit"),
|
||||
report=current.get("report"),
|
||||
personas=current.get("personas"),
|
||||
)
|
||||
except (TypeError, ValueError):
|
||||
# A malformed persisted ready record is recoverable, but
|
||||
# must never be trusted or exposed as ready.
|
||||
return self.update(
|
||||
candidate_id,
|
||||
status="draft",
|
||||
personas=[],
|
||||
)
|
||||
if normalized != current.get("personas"):
|
||||
current = self.update(candidate_id, personas=normalized)
|
||||
return current
|
||||
if status == "draft":
|
||||
raw_personas = current.get("personas")
|
||||
if raw_personas:
|
||||
try:
|
||||
_normalize_personas(raw_personas)
|
||||
except (TypeError, ValueError):
|
||||
return self.update(candidate_id, personas=[])
|
||||
raise ValueError("private group is not ready")
|
||||
return group
|
||||
group = self.create(
|
||||
org_id=org_id,
|
||||
creator_id=owner_user_id,
|
||||
title=f"{owner_name or 'User'}'s private personas",
|
||||
status="ready",
|
||||
owner_user_id=owner_user_id,
|
||||
input_data=input_data or {"channel": "social", "language": "th"},
|
||||
sales_kit=sales_kit or {
|
||||
"productName": "personal practice",
|
||||
"valueProps": [],
|
||||
"features": [],
|
||||
},
|
||||
)
|
||||
return group
|
||||
return current
|
||||
raise ValueError("private group is not ready")
|
||||
raise ValueError("private group state is unavailable")
|
||||
|
||||
def append_private_persona(
|
||||
self,
|
||||
*,
|
||||
org_id: str,
|
||||
owner_user_id: str,
|
||||
owner_name: str = "User",
|
||||
persona: dict[str, Any],
|
||||
input_data: dict[str, Any] | None = None,
|
||||
sales_kit: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
"""Append one persona without publishing a ready-empty private group.
|
||||
|
||||
A new private group is created with its first persona in the same
|
||||
persisted record. Existing groups are re-read and updated under their
|
||||
record lock. The collection lock covers lookup/create so concurrent
|
||||
variant requests cannot race into duplicate private groups.
|
||||
"""
|
||||
if not is_valid_tenant_id(org_id):
|
||||
raise ValueError("organization scope is invalid")
|
||||
if (
|
||||
not isinstance(owner_user_id, str)
|
||||
or not owner_user_id.strip()
|
||||
or owner_user_id != owner_user_id.strip()
|
||||
):
|
||||
raise ValueError("private owner is invalid")
|
||||
if not isinstance(persona, dict):
|
||||
raise ValueError("persona is invalid")
|
||||
if not isinstance(persona.get("id"), str) or not persona["id"].strip():
|
||||
raise ValueError("persona id is invalid")
|
||||
normalized = ensure_persona_shape(persona)
|
||||
validate_persona_traits(normalized)
|
||||
|
||||
for _ in range(3):
|
||||
candidate_id = None
|
||||
found_candidate = False
|
||||
with self.groups.collection_lock():
|
||||
for group in self.list_for_org(org_id=org_id):
|
||||
if group.get("owner_user_id") == owner_user_id:
|
||||
found_candidate = True
|
||||
candidate_id = group.get("id")
|
||||
break
|
||||
if not found_candidate:
|
||||
return self.create(
|
||||
org_id=org_id,
|
||||
creator_id=owner_user_id,
|
||||
title=f"{owner_name or 'User'}'s private personas",
|
||||
status="ready",
|
||||
owner_user_id=owner_user_id,
|
||||
input_data=input_data or {"channel": "social", "language": "th"},
|
||||
sales_kit=sales_kit or {
|
||||
"productName": "personal practice",
|
||||
"valueProps": [],
|
||||
"features": [],
|
||||
},
|
||||
report={"type": "private_persona_collection"},
|
||||
personas=[normalized],
|
||||
)
|
||||
|
||||
if (
|
||||
not isinstance(candidate_id, str)
|
||||
or not candidate_id.strip()
|
||||
or candidate_id != candidate_id.strip()
|
||||
):
|
||||
raise ValueError("private group state is invalid")
|
||||
with self.record_lock(candidate_id):
|
||||
current = self.get_or_none(candidate_id)
|
||||
if current is None:
|
||||
continue
|
||||
if (
|
||||
current.get("org_id") != org_id
|
||||
or current.get("owner_user_id") != owner_user_id
|
||||
):
|
||||
continue
|
||||
if not is_valid_owner_visibility(current):
|
||||
raise ValueError("private group state is invalid")
|
||||
raw_personas = current.get("personas")
|
||||
if raw_personas:
|
||||
try:
|
||||
current_personas = _normalize_personas(raw_personas)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("private group personas are invalid") from exc
|
||||
else:
|
||||
current_personas = []
|
||||
if current.get("status") == "draft" and current_personas:
|
||||
raise ValueError("private group is not ready")
|
||||
if current.get("status") not in {"draft", "ready"}:
|
||||
raise ValueError("private group is not ready")
|
||||
fields: dict[str, Any] = {
|
||||
"personas": current_personas + [normalized],
|
||||
"status": "ready",
|
||||
}
|
||||
if not isinstance(current.get("report"), dict) or not current.get("report"):
|
||||
fields["report"] = {"type": "private_persona_collection"}
|
||||
if not isinstance(current.get("sales_kit"), dict) or not current.get("sales_kit"):
|
||||
fields["sales_kit"] = sales_kit or {
|
||||
"productName": "personal practice",
|
||||
"valueProps": [],
|
||||
"features": [],
|
||||
}
|
||||
return self.update(candidate_id, **fields)
|
||||
raise ValueError("private group state is unavailable")
|
||||
|
||||
# ── personas ────────────────────────────────────────────────────────
|
||||
def set_personas(self, gid: str, personas: list[dict[str, Any]]) -> dict[str, Any]:
|
||||
if not isinstance(personas, list) or any(
|
||||
not isinstance(persona, dict) for persona in personas
|
||||
):
|
||||
raise ValueError("personas must be a list of objects")
|
||||
normalized = _normalize_personas(personas)
|
||||
with self.record_lock(gid):
|
||||
personas = [ensure_persona_shape(p) for p in personas]
|
||||
return self.groups.update(gid, personas=personas)
|
||||
current = self.get(gid)
|
||||
fields: dict[str, Any] = {"personas": normalized, "status": "ready"}
|
||||
if current.get("owner_user_id"):
|
||||
if not isinstance(current.get("report"), dict) or not current.get("report"):
|
||||
fields["report"] = {"type": "private_persona_collection"}
|
||||
if not isinstance(current.get("sales_kit"), dict) or not current.get("sales_kit"):
|
||||
fields["sales_kit"] = {
|
||||
"productName": "personal practice",
|
||||
"valueProps": [],
|
||||
"features": [],
|
||||
}
|
||||
return self.update(gid, **fields)
|
||||
|
||||
def get_persona(self, gid: str, pid: str) -> dict[str, Any] | None:
|
||||
group = self.get(gid)
|
||||
@@ -223,6 +735,8 @@ class GroupStore:
|
||||
def update_persona(self, gid: str, pid: str, patch: dict[str, Any]) -> dict[str, Any]:
|
||||
with self.record_lock(gid):
|
||||
group = self.get(gid)
|
||||
if group.get("status") != "ready":
|
||||
raise ValueError("ready group required")
|
||||
found = False
|
||||
raw_personas = group.get("personas")
|
||||
personas = raw_personas if isinstance(raw_personas, list) else []
|
||||
@@ -239,6 +753,10 @@ class GroupStore:
|
||||
break
|
||||
if not found:
|
||||
raise ValueError("persona not found")
|
||||
if not isinstance(group.get("sales_kit"), dict) or not group.get("sales_kit"):
|
||||
raise ValueError("ready group sales kit is invalid")
|
||||
if not isinstance(group.get("report"), dict) or not group.get("report"):
|
||||
raise ValueError("ready group report is invalid")
|
||||
return self.groups.replace(gid, group)
|
||||
|
||||
def delete(self, gid: str) -> None:
|
||||
|
||||
@@ -79,7 +79,27 @@ class SessionStore:
|
||||
@staticmethod
|
||||
def _mode(row: dict[str, Any]) -> str:
|
||||
# Records created before the explicit mode field are normal trainee attempts.
|
||||
return row.get("mode") or "trainee"
|
||||
mode = row.get("mode")
|
||||
if mode is None:
|
||||
return "trainee"
|
||||
return mode if isinstance(mode, str) else ""
|
||||
|
||||
|
||||
def _raw_scope_rows(
|
||||
self,
|
||||
*,
|
||||
org_id: str,
|
||||
user_id: str,
|
||||
group_id: str,
|
||||
persona_id: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
return self.sessions.where(
|
||||
lambda row: isinstance(row, dict)
|
||||
and row.get("org_id") == org_id
|
||||
and row.get("user_id") == user_id
|
||||
and row.get("group_id") == group_id
|
||||
and row.get("persona_id") == persona_id
|
||||
)
|
||||
|
||||
def _scope_rows(
|
||||
self,
|
||||
@@ -90,14 +110,16 @@ class SessionStore:
|
||||
persona_id: str,
|
||||
mode: str,
|
||||
) -> list[dict[str, Any]]:
|
||||
return self.sessions.where(
|
||||
lambda row: isinstance(row, dict)
|
||||
and row.get("org_id") == org_id
|
||||
and row.get("user_id") == user_id
|
||||
and row.get("group_id") == group_id
|
||||
and row.get("persona_id") == persona_id
|
||||
and self._mode(row) == mode
|
||||
)
|
||||
return [
|
||||
row
|
||||
for row in self._raw_scope_rows(
|
||||
org_id=org_id,
|
||||
user_id=user_id,
|
||||
group_id=group_id,
|
||||
persona_id=persona_id,
|
||||
)
|
||||
if isinstance(row, dict) and self._mode(row) == mode
|
||||
]
|
||||
|
||||
def start(
|
||||
self,
|
||||
@@ -121,6 +143,28 @@ class SessionStore:
|
||||
"""
|
||||
self._validate_scope(org_id, user_id, group_id, persona_id, mode)
|
||||
with self.sessions.collection_lock():
|
||||
raw_rows = [
|
||||
row
|
||||
for row in self._raw_scope_rows(
|
||||
org_id=org_id,
|
||||
user_id=user_id,
|
||||
group_id=group_id,
|
||||
persona_id=persona_id,
|
||||
)
|
||||
if isinstance(row, dict)
|
||||
]
|
||||
for row in raw_rows:
|
||||
row_mode = self._mode(row)
|
||||
status = row.get("status")
|
||||
outcome = row.get("outcome")
|
||||
if (
|
||||
row_mode not in ("trainee", "preview")
|
||||
or status not in ("active", "finished")
|
||||
or outcome not in (None, "won", "lost", "abandoned")
|
||||
or (status == "active" and outcome is not None)
|
||||
or (status == "finished" and outcome not in ("won", "lost", "abandoned"))
|
||||
):
|
||||
raise ValueError("invalid persisted session state")
|
||||
rows = self._scope_rows(
|
||||
org_id=org_id,
|
||||
user_id=user_id,
|
||||
|
||||
@@ -14,6 +14,95 @@ from ..llm import LLMClient, LLMError
|
||||
|
||||
MAX_PERSONA_REPLY_CHARS = 4000
|
||||
|
||||
|
||||
def _safe_roleplay_internal(value: object) -> dict[str, Any]:
|
||||
"""Keep judge-only state out of the customer role-play prompt."""
|
||||
if not isinstance(value, dict):
|
||||
return {}
|
||||
safe: dict[str, Any] = {}
|
||||
for key in ("turns", "misses", "score"):
|
||||
item = value.get(key)
|
||||
if isinstance(item, int) and not isinstance(item, bool):
|
||||
safe[key] = item
|
||||
recontact_done = value.get("recontact_done")
|
||||
if isinstance(recontact_done, bool):
|
||||
safe["recontact_done"] = recontact_done
|
||||
signals = value.get("signals")
|
||||
if isinstance(signals, list):
|
||||
safe_signals = []
|
||||
for signal in signals[-20:]:
|
||||
if not isinstance(signal, dict):
|
||||
continue
|
||||
turn = signal.get("turn")
|
||||
mood = signal.get("mood")
|
||||
signal_type = signal.get("type")
|
||||
if (
|
||||
isinstance(turn, int)
|
||||
and not isinstance(turn, bool)
|
||||
and isinstance(mood, int)
|
||||
and not isinstance(mood, bool)
|
||||
and signal_type in {"annoy", "warm"}
|
||||
):
|
||||
safe_signals.append({"turn": turn, "mood": mood, "type": signal_type})
|
||||
safe["signals"] = safe_signals
|
||||
return safe
|
||||
|
||||
|
||||
def _safe_prompt_text(value: object, default: str = "", limit: int = 4000) -> str:
|
||||
if isinstance(value, str):
|
||||
return value[:limit]
|
||||
if isinstance(value, (int, float, bool)):
|
||||
return str(value)[:limit]
|
||||
return default
|
||||
|
||||
|
||||
def _safe_persona(value: object) -> dict[str, Any]:
|
||||
"""Normalize persisted persona values before prompt or arithmetic use."""
|
||||
raw = value if isinstance(value, dict) else {}
|
||||
safe = dict(raw)
|
||||
for key in (
|
||||
"name", "profession", "age_group", "location", "product_context",
|
||||
"background", "income", "lifestyle", "personality", "communication_style",
|
||||
"budget", "decision_timeline", "goal", "opener", "special", "notes",
|
||||
):
|
||||
safe[key] = _safe_prompt_text(raw.get(key), "")
|
||||
channel = raw.get("channel")
|
||||
safe["channel"] = channel if isinstance(channel, str) and channel in {"facebook", "line"} else "social"
|
||||
initiation_mode = raw.get("initiation_mode")
|
||||
safe["initiation_mode"] = (
|
||||
initiation_mode
|
||||
if isinstance(initiation_mode, str) and initiation_mode in {"customer", "seller"}
|
||||
else "customer"
|
||||
)
|
||||
tier = raw.get("tier")
|
||||
safe["tier"] = tier if isinstance(tier, str) and tier in {"A", "B", "C"} else "B"
|
||||
safe["recontact"] = raw.get("recontact") is True
|
||||
try:
|
||||
tolerance = int(raw.get("tolerance", 3) or 3)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
tolerance = 3
|
||||
safe["tolerance"] = max(1, min(10, tolerance))
|
||||
for key in ("pains", "negotiation_levers", "objections"):
|
||||
safe[key] = raw.get(key) if isinstance(raw.get(key), list) else []
|
||||
return safe
|
||||
|
||||
|
||||
def _safe_transcript(value: object) -> list[dict[str, str]]:
|
||||
"""Drop malformed transcript rows before they reach the simulator."""
|
||||
if not isinstance(value, list):
|
||||
return []
|
||||
safe: list[dict[str, str]] = []
|
||||
for item in value[-100:]:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
role = item.get("role")
|
||||
text = item.get("text")
|
||||
if role not in {"system", "seller", "customer"} or not isinstance(text, str):
|
||||
continue
|
||||
safe.append({"role": role, "text": text[:4000]})
|
||||
return safe
|
||||
|
||||
|
||||
CHAT_SYSTEM = """You are playing a REALISTIC customer named {name} in a sales-training chat.
|
||||
Stay perfectly in character at ALL times. Use {tone}.
|
||||
|
||||
@@ -102,6 +191,8 @@ class Simulator:
|
||||
scenario_adapt: str = "",
|
||||
) -> tuple[str, dict[str, Any]]:
|
||||
"""Return (reply_text, meta) where meta includes decision/mood from the persona."""
|
||||
persona = _safe_persona(persona)
|
||||
messages = _safe_transcript(messages)
|
||||
pains_txt = self._describe_pains(persona.get("pains", []))
|
||||
adapt = scenario_adapt or {
|
||||
"social": "Chat style: short, casual, quick social-messaging replies.",
|
||||
@@ -139,14 +230,18 @@ class Simulator:
|
||||
msgs.append({
|
||||
"role": "system",
|
||||
"content": "Internal state (for your role-play only): "
|
||||
+ json.dumps(internal, ensure_ascii=False),
|
||||
+ json.dumps(_safe_roleplay_internal(internal), ensure_ascii=False),
|
||||
})
|
||||
for m in messages[-30:]:
|
||||
role = m.get("role")
|
||||
text = m.get("text")
|
||||
if not isinstance(text, str):
|
||||
continue
|
||||
if role == "system":
|
||||
msgs.append({"role": "system", "content": f"[scene note from transcript]: {m.get('text')}"})
|
||||
else:
|
||||
msgs.append({"role": role, "content": m.get("text", "")})
|
||||
if text.startswith("⏳ "):
|
||||
msgs.append({"role": "system", "content": f"[scene note from transcript]: {text[:4000]}"})
|
||||
elif role in {"seller", "customer"}:
|
||||
msgs.append({"role": role, "content": text[:4000]})
|
||||
for attempt in range(2):
|
||||
if attempt:
|
||||
msgs.append({
|
||||
@@ -166,6 +261,9 @@ class Simulator:
|
||||
derived from the conversation context — NOT from fixed keywords. Returns
|
||||
{mood, decision(buy|walk|pending), score_delta, reason}.
|
||||
"""
|
||||
persona = _safe_persona(persona)
|
||||
messages = _safe_transcript(messages)
|
||||
internal = _safe_roleplay_internal(internal)
|
||||
transcript = "\n".join(
|
||||
f"{m.get('role')}: {m.get('text')}" for m in messages[-30:]
|
||||
)
|
||||
@@ -224,6 +322,9 @@ class Simulator:
|
||||
messages: list[dict[str, str]],
|
||||
internal: dict[str, Any] | None = None,
|
||||
) -> dict[str, Any]:
|
||||
persona = _safe_persona(persona)
|
||||
messages = _safe_transcript(messages)
|
||||
internal = _safe_roleplay_internal(internal)
|
||||
persona_summary = json.dumps({
|
||||
"name": persona.get("name"),
|
||||
"pains": persona.get("pains", []),
|
||||
@@ -251,7 +352,7 @@ class Simulator:
|
||||
except LLMError as exc:
|
||||
raise
|
||||
if not isinstance(result, dict):
|
||||
result = {}
|
||||
raise LLMError("judge returned an invalid response")
|
||||
result.setdefault("outcome", "lost")
|
||||
result.setdefault("score", 0)
|
||||
result.setdefault("pain", "")
|
||||
@@ -261,6 +362,49 @@ class Simulator:
|
||||
result.setdefault("painProgress", {})
|
||||
return result
|
||||
|
||||
def public_debrief(
|
||||
self,
|
||||
*,
|
||||
messages: list[dict[str, str]],
|
||||
outcome: str,
|
||||
score: int,
|
||||
) -> dict[str, Any]:
|
||||
"""Generate trainee coaching without sending hidden persona data.
|
||||
|
||||
The final judge receives latent persona context and its prose is therefore
|
||||
never safe to expose. This second, public-only pass sees only the seller's
|
||||
own messages plus the already-computed outcome/score, so it cannot repeat
|
||||
hidden pains, budget, opener, or other persona fields.
|
||||
"""
|
||||
messages = _safe_transcript(messages)
|
||||
seller_transcript = "\n".join(
|
||||
str(message.get("text", ""))
|
||||
for message in messages[-40:]
|
||||
if isinstance(message, dict)
|
||||
and message.get("role") == "seller"
|
||||
and isinstance(message.get("text"), str)
|
||||
)
|
||||
system = (
|
||||
"You are a sales communication coach. Produce feedback only from the seller's "
|
||||
"observable messages below. Do not infer, mention, quote, or invent any customer "
|
||||
"profile, pain, budget, personality, opener, objection, negotiation lever, or "
|
||||
"other hidden context. Do not reveal customer information. Return only valid JSON: "
|
||||
'{"why": "short observable explanation", "failurePoints": ["seller behavior"], '
|
||||
'"coaching": ["specific communication improvement"]}. '
|
||||
"If there is not enough seller text, return empty strings/lists."
|
||||
)
|
||||
user_prompt = (
|
||||
f"OUTCOME: {outcome}\nSCORE: {score}\n"
|
||||
f"SELLER MESSAGES ONLY:\n{seller_transcript}"
|
||||
)
|
||||
result = self.judge_llm.complete_json(
|
||||
system,
|
||||
user_prompt,
|
||||
temperature=0.2,
|
||||
max_tokens=1200,
|
||||
)
|
||||
return result if isinstance(result, dict) else {}
|
||||
|
||||
# ── helpers ────────────────────────────────────────────────────────
|
||||
def _describe_pains(self, pains: list[Any]) -> str:
|
||||
if not pains:
|
||||
|
||||
@@ -10,6 +10,7 @@ conditions, `negotiation_levers`, and optional `special` flags (e.g. wrong_text)
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Any
|
||||
|
||||
DEFAULT_TIERS = ["A", "B", "C"]
|
||||
@@ -19,14 +20,64 @@ PERSONA_INITIATION_MODES = ("customer", "seller")
|
||||
|
||||
def validate_persona_traits(p: dict[str, Any]) -> None:
|
||||
"""Reject invalid behavior traits instead of silently changing their meaning."""
|
||||
def _bounded_json(value: object, *, depth: int = 0) -> bool:
|
||||
if depth > 6:
|
||||
return False
|
||||
if value is None or isinstance(value, bool):
|
||||
return True
|
||||
if isinstance(value, str):
|
||||
return len(value) <= 4000
|
||||
if isinstance(value, (int, float)) and not isinstance(value, bool):
|
||||
return math.isfinite(value)
|
||||
if isinstance(value, list):
|
||||
return len(value) <= 100 and all(
|
||||
_bounded_json(item, depth=depth + 1) for item in value
|
||||
)
|
||||
if isinstance(value, dict):
|
||||
return len(value) <= 100 and all(
|
||||
isinstance(key, str)
|
||||
and len(key) <= 200
|
||||
and _bounded_json(item, depth=depth + 1)
|
||||
for key, item in value.items()
|
||||
)
|
||||
return False
|
||||
|
||||
if not isinstance(p, dict) or not _bounded_json(p):
|
||||
raise ValueError("persona contains invalid or unbounded data")
|
||||
if p.get("channel") not in PERSONA_CHANNELS:
|
||||
raise ValueError("persona channel must be facebook or line")
|
||||
if p.get("initiation_mode") not in PERSONA_INITIATION_MODES:
|
||||
raise ValueError("persona initiation_mode must be customer or seller")
|
||||
scalar_fields = (
|
||||
"id", "source_persona_id", "name", "tier", "profession", "age_group",
|
||||
"location", "product_context", "background", "income", "lifestyle",
|
||||
"personality", "communication_style", "budget", "decision_timeline",
|
||||
"goal", "opener", "special", "notes",
|
||||
)
|
||||
for field in scalar_fields:
|
||||
value = p.get(field)
|
||||
if value is not None and (not isinstance(value, str) or len(value) > 4000):
|
||||
raise ValueError(f"persona {field} must be a bounded string")
|
||||
for field in ("objections", "pains", "negotiation_levers"):
|
||||
value = p.get(field)
|
||||
if not isinstance(value, list) or len(value) > 100:
|
||||
raise ValueError(f"persona {field} must be a bounded list")
|
||||
if not isinstance(p.get("recontact"), bool):
|
||||
raise ValueError("persona recontact must be boolean")
|
||||
for field in ("difficulty", "tolerance"):
|
||||
value = p.get(field)
|
||||
if type(value) is not int or not 1 <= value <= 5:
|
||||
raise ValueError(f"persona {field} must be between 1 and 5")
|
||||
|
||||
|
||||
def ensure_persona_shape(p: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Fill defaults so a persona dict is always structurally complete."""
|
||||
if "tier" in p and p["tier"] not in DEFAULT_TIERS:
|
||||
raise ValueError("persona tier must be A, B, or C")
|
||||
if "intent_tier" in p and p["intent_tier"] not in DEFAULT_TIERS:
|
||||
raise ValueError("persona intent_tier must be A, B, or C")
|
||||
if "recontact" in p and not isinstance(p["recontact"], bool):
|
||||
raise ValueError("persona recontact must be boolean")
|
||||
pid = p.get("id") or p.get("name", "persona")
|
||||
base = {
|
||||
"id": pid,
|
||||
@@ -59,11 +110,6 @@ def ensure_persona_shape(p: dict[str, Any]) -> dict[str, Any]:
|
||||
"tolerance": p.get("tolerance", 3), # misses before this persona walks away (temper)
|
||||
"notes": p.get("notes", ""),
|
||||
}
|
||||
# validation
|
||||
if base["tier"] not in DEFAULT_TIERS:
|
||||
base["tier"] = "B"
|
||||
if base["initiation_mode"] not in PERSONA_INITIATION_MODES:
|
||||
base["initiation_mode"] = "customer"
|
||||
return base
|
||||
|
||||
|
||||
|
||||
@@ -81,7 +81,7 @@ def _finished_trainee_sessions(sessions: list[dict[str, Any]]) -> list[dict[str,
|
||||
session
|
||||
for session in sessions
|
||||
if isinstance(session, dict)
|
||||
if (session.get("mode") or "trainee") == "trainee"
|
||||
if (session.get("mode") is None or session.get("mode") == "trainee")
|
||||
and session.get("status") == "finished"
|
||||
and session.get("outcome") in ("won", "lost")
|
||||
]
|
||||
|
||||
@@ -18,6 +18,16 @@ blinker==1.9.0 \
|
||||
--hash=sha256:b4ce2265a7abece45e7cc896e98dbebe6cead56bcf805a3d23136d145f5445bf \
|
||||
--hash=sha256:ba0efaa9080b619ff2f3459d1d500c57bddea4a6b424b60a91141db6fd2f08bc
|
||||
# via flask
|
||||
boto3==1.43.78 \
|
||||
--hash=sha256:2fa59116e298171ef59e7600a8be6c01177faef8af4b9a4314b7a57a04009ada \
|
||||
--hash=sha256:893f06a171469618e17de78dc927aca6e74fcf45a70d2c5918e9ac9919e96cc9
|
||||
# via -r requirements.txt
|
||||
botocore==1.43.78 \
|
||||
--hash=sha256:ddd020493235e264b3bd12606f239a3d3b2dd7cfb1d25a0691061183c290c228 \
|
||||
--hash=sha256:e8238d22c1e1342025d75d2e33d154a375e7caad0fc67f77d77faa2d82668b94
|
||||
# via
|
||||
# boto3
|
||||
# s3transfer
|
||||
certifi==2026.7.22 \
|
||||
--hash=sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775 \
|
||||
--hash=sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55
|
||||
@@ -390,6 +400,12 @@ jiter==0.16.0 \
|
||||
--hash=sha256:f4444a83f946605990c98f625cdd3d2725bfb818158760c5748c653170a20e0e \
|
||||
--hash=sha256:fb08c276dd02dac3a284acdd02cacc630d2e3cd6572a4b85519f35cbd133c3de
|
||||
# via openai
|
||||
jmespath==1.1.0 \
|
||||
--hash=sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d \
|
||||
--hash=sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64
|
||||
# via
|
||||
# boto3
|
||||
# botocore
|
||||
lxml==6.1.2 \
|
||||
--hash=sha256:0349321a0537d4fdbebb2af06dd1b64676132c72e2ae250de8cdb58f8c43019c \
|
||||
--hash=sha256:04cf9e3f4ee9cab9d9ba05401bef8668840fa9620fcd4d8e85a2d2fd0b0fa960 \
|
||||
@@ -899,6 +915,10 @@ pytest==8.4.2 \
|
||||
--hash=sha256:86c0d0b93306b961d58d62a4db4879f27fe25513d4b969df351abdddb3c30e01 \
|
||||
--hash=sha256:872f880de3fc3a5bdc88a11b39c9710c3497a547cfa9320bc3c5e62fbf272e79
|
||||
# via -r requirements.txt
|
||||
python-dateutil==2.9.0.post0 \
|
||||
--hash=sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3 \
|
||||
--hash=sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427
|
||||
# via botocore
|
||||
python-docx==1.2.0 \
|
||||
--hash=sha256:3fd478f3250fbbbfd3b94fe1e985955737c145627498896a8a6bf81f4baf66c7 \
|
||||
--hash=sha256:7bc9d7b7d8a69c9c02ca09216118c86552704edc23bac179283f2e38f86220ce
|
||||
@@ -907,6 +927,14 @@ python-dotenv==1.2.2 \
|
||||
--hash=sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a \
|
||||
--hash=sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3
|
||||
# via -r requirements.txt
|
||||
s3transfer==0.19.2 \
|
||||
--hash=sha256:ba0309fd86be3c27dbf78cdd813c13c5e1df16e5874b99d2535ebbdfb9892993 \
|
||||
--hash=sha256:d8168eccca828cbb2cd573675333f3bddd254313a9c42494b84c76b539e8ba25
|
||||
# via boto3
|
||||
six==1.17.0 \
|
||||
--hash=sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274 \
|
||||
--hash=sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81
|
||||
# via python-dateutil
|
||||
sniffio==1.3.1 \
|
||||
--hash=sha256:2f6da418d1f1e0fddd844478f41680e794e6051915791a034ff65e5f100525a2 \
|
||||
--hash=sha256:f4324edc670a0f49750a81b895f35c3adb843cca46f0530f79fc1babb23789dc
|
||||
@@ -992,6 +1020,10 @@ tzdata==2026.3 ; sys_platform == 'win32' \
|
||||
--hash=sha256:4a1518b8993086a7982523e071643f3c0e5f213e75b21318e78bcabfff9d1415 \
|
||||
--hash=sha256:dc096730c87af6cab1b171c9d532be840741ff5d459015e7f6947bd7d7e54931
|
||||
# via psycopg
|
||||
urllib3==2.7.0 \
|
||||
--hash=sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c \
|
||||
--hash=sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897
|
||||
# via botocore
|
||||
werkzeug==3.1.8 \
|
||||
--hash=sha256:63a77fb8892bf28ebc3178683445222aa500e48ebad5ec77b0ad80f8726b1f50 \
|
||||
--hash=sha256:9bad61a4268dac112f1c5cd4630a56ede601b6ed420300677a869083d70a4c44
|
||||
|
||||
@@ -13,4 +13,5 @@ gunicorn==26.0.0
|
||||
pytest==8.4.2
|
||||
SQLAlchemy==2.0.52
|
||||
alembic==1.19.1
|
||||
boto3==1.43.78
|
||||
psycopg[binary]==3.3.4
|
||||
|
||||
@@ -39,7 +39,17 @@ def client(app):
|
||||
|
||||
@pytest.fixture()
|
||||
def user_store(app):
|
||||
return app.extensions["user_store"]
|
||||
store = app.extensions["user_store"]
|
||||
original_create_user = store.create_user
|
||||
|
||||
def create_test_user(*args, **kwargs):
|
||||
# Existing route tests create already-authorized identities directly;
|
||||
# explicit accepted_terms=False remains available for consent tests.
|
||||
kwargs.setdefault("accepted_terms", True)
|
||||
return original_create_user(*args, **kwargs)
|
||||
|
||||
store.create_user = create_test_user
|
||||
return store
|
||||
|
||||
|
||||
def auth_headers(token: str) -> dict[str, str]:
|
||||
|
||||
@@ -130,4 +130,4 @@ def test_admin_cannot_start_session_in_another_org(client, user_store, login):
|
||||
headers=_headers(token),
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
assert response.status_code == 404
|
||||
|
||||
@@ -18,6 +18,37 @@ def _create_org_admin(user_store, *, org_id: str, username: str, password: str):
|
||||
)
|
||||
|
||||
|
||||
def test_admin_directory_responses_use_explicit_public_allowlists(
|
||||
client, user_store, login
|
||||
):
|
||||
admin = _create_org_admin(
|
||||
user_store, org_id="org-default", username="admin-directory", password="admin-directory-password"
|
||||
)
|
||||
user_store.orgs.update(
|
||||
"org-default",
|
||||
connection_string="postgresql://[REDACTED]",
|
||||
internal_secret="do-not-return",
|
||||
)
|
||||
token = login(admin["username"], "admin-directory-password")["token"]
|
||||
user_store.users.update(
|
||||
admin["username"],
|
||||
password_hash="[REDACTED]",
|
||||
api_token="do-not-return",
|
||||
)
|
||||
|
||||
org_response = client.get("/api/admin/orgs", headers=auth_headers(token))
|
||||
users_response = client.get("/api/admin/users", headers=auth_headers(token))
|
||||
|
||||
assert org_response.status_code == 200, org_response.get_json()
|
||||
assert users_response.status_code == 200, users_response.get_json()
|
||||
org = org_response.get_json()["orgs"][0]
|
||||
assert set(org) <= {"id", "name", "plan", "seats", "active", "created_at"}
|
||||
assert "connection_string" not in org
|
||||
assert "internal_secret" not in org
|
||||
assert all("password_hash" not in user for user in users_response.get_json()["users"])
|
||||
assert all("api_token" not in user for user in users_response.get_json()["users"])
|
||||
|
||||
|
||||
def test_tenant_admin_cannot_provision_into_another_org(
|
||||
client, user_store, login
|
||||
):
|
||||
@@ -64,7 +95,10 @@ def test_tenant_admin_can_provision_without_org_id_into_own_org(
|
||||
|
||||
assert response.status_code == 201, response.get_json()
|
||||
assert response.get_json()["org_id"] == "org-default"
|
||||
assert user_store.get_user("own-org-user")["org_id"] == "org-default"
|
||||
created = user_store.get_user("own-org-user")
|
||||
assert created["org_id"] == "org-default"
|
||||
assert created["must_setup"] is True
|
||||
assert created["accepted_terms"] is False
|
||||
|
||||
|
||||
def test_tenant_admin_cannot_update_user_in_another_org(
|
||||
@@ -96,6 +130,37 @@ def test_tenant_admin_cannot_update_user_in_another_org(
|
||||
assert user_store.get_user("org-b-victim") == before
|
||||
|
||||
|
||||
def test_tenant_admin_cannot_enumerate_cross_org_demo_user(
|
||||
client, user_store, login, monkeypatch
|
||||
):
|
||||
from app.config import Config
|
||||
|
||||
monkeypatch.setattr(Config, "DEMO_ORG_ID", "org-b")
|
||||
org_b = user_store.create_org("Organization B", org_id="org-b")
|
||||
admin_a = _create_org_admin(
|
||||
user_store, org_id="org-default", username="admin-demo-enum", password="admin-demo-enum-password"
|
||||
)
|
||||
user_store.create_user(
|
||||
org_id=org_b["id"],
|
||||
username="cross-org-demo",
|
||||
password="demo-password",
|
||||
name="Cross Org Demo",
|
||||
role="demo",
|
||||
email="cross-org-demo@example.com",
|
||||
allow_demo=True,
|
||||
)
|
||||
token = login(admin_a["username"], "admin-demo-enum-password")["token"]
|
||||
|
||||
response = client.put(
|
||||
"/api/admin/users/cross-org-demo",
|
||||
json={"email": "attacker@example.com"},
|
||||
headers=auth_headers(token),
|
||||
)
|
||||
|
||||
assert response.status_code == 404, response.get_json()
|
||||
assert response.get_json() == {"error": "user not found"}
|
||||
|
||||
|
||||
def test_tenant_admin_cannot_update_super_admin(
|
||||
client, user_store, login
|
||||
):
|
||||
|
||||
@@ -177,6 +177,52 @@ def test_setup_updates_only_the_authenticated_user(client, user_store, login):
|
||||
assert updated["must_setup"] is False
|
||||
|
||||
|
||||
def test_inconsistent_unaccepted_account_is_revoked(
|
||||
client, user_store, login
|
||||
):
|
||||
user = user_store.create_user(
|
||||
org_id="org-default",
|
||||
username="legacy-unaccepted-user",
|
||||
password="legacy-unaccepted-password",
|
||||
name="Legacy Unaccepted User",
|
||||
role="user",
|
||||
must_setup=False,
|
||||
accepted_terms=False,
|
||||
)
|
||||
token = login(user["username"], "legacy-unaccepted-password")["token"]
|
||||
headers = {"Authorization": f"Bearer {token}"}
|
||||
|
||||
assert client.get("/api/auth/me", headers=headers).status_code == 401
|
||||
blocked = client.get("/api/groups", headers=headers)
|
||||
assert blocked.status_code == 401, blocked.get_json()
|
||||
|
||||
|
||||
def test_missing_setup_marker_is_rejected_at_login_without_state_leak(
|
||||
client, user_store
|
||||
):
|
||||
user = user_store.create_user(
|
||||
org_id="org-default",
|
||||
username="legacy-missing-setup-marker",
|
||||
password="legacy-missing-setup-password",
|
||||
name="Legacy Missing Setup Marker",
|
||||
role="user",
|
||||
must_setup=False,
|
||||
accepted_terms=True,
|
||||
)
|
||||
persisted = dict(user)
|
||||
persisted.pop("must_setup")
|
||||
user_store.users.replace(user["username"], persisted)
|
||||
response = client.post(
|
||||
"/api/auth/login",
|
||||
json={
|
||||
"username": user["username"],
|
||||
"password": "legacy-missing-setup-password",
|
||||
},
|
||||
)
|
||||
assert response.status_code == 401, response.get_json()
|
||||
assert response.get_json() == {"error": "invalid credentials"}
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"terms_payload",
|
||||
[{}, {"accepted_terms": False}, {"accepted_terms": "true"}],
|
||||
@@ -827,4 +873,129 @@ def test_complete_setup_is_atomic_across_worker_processes(user_store):
|
||||
for process in processes:
|
||||
if process.is_alive():
|
||||
process.terminate()
|
||||
process.join(timeout=2)
|
||||
process.join(timeout=2)
|
||||
|
||||
|
||||
def test_register_never_rearms_bootstrap_promotion_under_concurrency(app, monkeypatch):
|
||||
"""Deleting every user cannot re-arm public super-admin promotion."""
|
||||
store = app.extensions["user_store"]
|
||||
store.users.delete("admin")
|
||||
|
||||
original_create_user = store.create_user
|
||||
entered_create = threading.Barrier(2)
|
||||
|
||||
def synchronized_create_user(*args, **kwargs):
|
||||
try:
|
||||
entered_create.wait(timeout=1)
|
||||
except threading.BrokenBarrierError:
|
||||
pass
|
||||
return original_create_user(*args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(store, "create_user", synchronized_create_user)
|
||||
|
||||
def register(index: int) -> dict:
|
||||
with app.test_client() as local_client:
|
||||
response = local_client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": f"first-register-{index}",
|
||||
"password": "first-register-password",
|
||||
"email": f"first-register-{index}@example.com",
|
||||
"accepted_terms": True,
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201, response.get_json()
|
||||
return response.get_json()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=2) as pool:
|
||||
responses = list(pool.map(register, (1, 2)))
|
||||
|
||||
roles = [response["user"]["role"] for response in responses]
|
||||
assert roles.count("super_admin") == 0
|
||||
assert roles.count("user") == 2
|
||||
|
||||
|
||||
def test_register_requires_and_records_terms(client, user_store):
|
||||
missing_terms = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": "terms-required-user",
|
||||
"password": "terms-required-password",
|
||||
"email": "terms-required@example.com",
|
||||
"accepted_terms": False,
|
||||
},
|
||||
)
|
||||
|
||||
assert missing_terms.status_code == 400, missing_terms.get_json()
|
||||
assert user_store.get_user_or_none("terms-required-user") is None
|
||||
|
||||
accepted = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": "terms-accepted-user",
|
||||
"password": "terms-accepted-password",
|
||||
"email": "terms-accepted@example.com",
|
||||
"accepted_terms": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert accepted.status_code == 201, accepted.get_json()
|
||||
public_user = accepted.get_json()["user"]
|
||||
assert public_user["accepted_terms"] is True
|
||||
assert isinstance(public_user["accepted_terms_at"], str)
|
||||
persisted = user_store.get_user("terms-accepted-user")
|
||||
assert persisted["accepted_terms"] is True
|
||||
assert persisted["accepted_terms_at"] == public_user["accepted_terms_at"]
|
||||
|
||||
|
||||
def test_register_rejects_placeholder_default_org(client, user_store, monkeypatch):
|
||||
from app.config import Config
|
||||
|
||||
monkeypatch.setattr(Config, "OAUTH_DEFAULT_ORG", "replace_me")
|
||||
|
||||
response = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": "placeholder-org-user",
|
||||
"password": "placeholder-org-password",
|
||||
"email": "placeholder-org@example.com",
|
||||
"accepted_terms": True,
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 503, response.get_json()
|
||||
assert user_store.get_user_or_none("placeholder-org-user") is None
|
||||
|
||||
|
||||
def test_setup_records_an_explicit_utc_consent_timestamp(
|
||||
client, user_store, login, monkeypatch
|
||||
):
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.api import auth_routes
|
||||
|
||||
fixed_now = datetime(2026, 8, 24, 6, 30, 0, tzinfo=timezone.utc)
|
||||
monkeypatch.setattr(auth_routes, "utc_now", lambda: fixed_now, raising=False)
|
||||
user_store.create_user(
|
||||
org_id="org-default",
|
||||
username="utc-setup-user",
|
||||
password="utc-setup-old-password",
|
||||
name="UTC Setup User",
|
||||
role="user",
|
||||
must_setup=True,
|
||||
)
|
||||
token = login("utc-setup-user", "utc-setup-old-password")["token"]
|
||||
|
||||
response = client.post(
|
||||
"/api/auth/setup",
|
||||
json={
|
||||
"email": "utc-setup@example.com",
|
||||
"password": "utc-setup-new-password",
|
||||
"accepted_terms": True,
|
||||
},
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
persisted = user_store.get_user("utc-setup-user")
|
||||
assert persisted["accepted_terms_at"] == fixed_now.isoformat()
|
||||
584
backend/tests/test_demo_accounts.py
Normal file
584
backend/tests/test_demo_accounts.py
Normal file
@@ -0,0 +1,584 @@
|
||||
"""Focused contracts for demo accounts, visibility gates, and delivery safety."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime as dt
|
||||
import json
|
||||
from urllib.error import URLError
|
||||
from urllib.request import Request
|
||||
|
||||
import pytest
|
||||
|
||||
from app.auth import users as users_module
|
||||
from app.auth.users import AuthError
|
||||
from app.config import Config
|
||||
from app.services.demo_delivery import _HttpsOnlyRedirectHandler
|
||||
from scripts.mock_llm import MockLLM
|
||||
|
||||
|
||||
FIXED_NOW = dt.datetime(2026, 8, 23, 12, 0, tzinfo=dt.timezone.utc)
|
||||
|
||||
|
||||
def _headers(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _super_admin_token(user_store, login) -> str:
|
||||
user_store.complete_setup(
|
||||
"admin",
|
||||
"admin@example.com",
|
||||
"admin-ready-password",
|
||||
accepted_terms=True,
|
||||
accepted_terms_at="2026-08-23T00:00:00Z",
|
||||
)
|
||||
return login("admin", "admin-ready-password")["token"]
|
||||
|
||||
|
||||
def _ready_group(app, *, org_id: str, creator_id: str, visibility: str, suffix: str) -> tuple[str, str]:
|
||||
groups = app.extensions["group_store"]
|
||||
group = groups.create(
|
||||
org_id=org_id,
|
||||
creator_id=creator_id,
|
||||
title=f"{visibility}-{suffix}",
|
||||
visibility=visibility,
|
||||
input_data={"product": f"Product {suffix}", "channel": "facebook", "language": "en"},
|
||||
sales_kit={"productName": f"Product {suffix}"},
|
||||
report={"summary": "Test report"},
|
||||
)
|
||||
persona_id = f"persona-{suffix}"
|
||||
groups.update(
|
||||
group["id"],
|
||||
status="ready",
|
||||
personas=[
|
||||
{
|
||||
"id": persona_id,
|
||||
"name": f"Customer {suffix}",
|
||||
"tier": "A",
|
||||
"channel": "facebook",
|
||||
"initiation_mode": "customer",
|
||||
"opener": "Hello, I have a question.",
|
||||
}
|
||||
],
|
||||
)
|
||||
return group["id"], persona_id
|
||||
|
||||
|
||||
def _active_demo_token(user_store, *, username: str, email: str) -> tuple[dict, str]:
|
||||
user_store.ensure_demo_org()
|
||||
user = user_store.create_demo_user(
|
||||
email=email,
|
||||
username=username,
|
||||
password="demo-original-password",
|
||||
)
|
||||
# This helper is for business-route authorization tests; the HTTP endpoint
|
||||
# separately covers the required first-time setup flow.
|
||||
user_store.users.update(
|
||||
username,
|
||||
must_setup=False,
|
||||
accepted_terms=True,
|
||||
accepted_terms_at=FIXED_NOW.isoformat(),
|
||||
)
|
||||
active = user_store.start_demo_trial(username, now=FIXED_NOW)
|
||||
return active, user_store.issue_token(active)
|
||||
|
||||
|
||||
def test_demo_creation_is_super_admin_only_and_generic_demo_creation_is_blocked(
|
||||
client, user_store, login
|
||||
):
|
||||
super_token = _super_admin_token(user_store, login)
|
||||
admin = user_store.create_user(
|
||||
org_id="org-default",
|
||||
username="tenant-admin-demo-gate",
|
||||
password="tenant-admin-demo-password",
|
||||
name="Tenant Admin",
|
||||
role="admin",
|
||||
)
|
||||
user = user_store.create_user(
|
||||
org_id="org-default",
|
||||
username="plain-demo-gate-user",
|
||||
password="plain-demo-gate-password",
|
||||
name="Plain User",
|
||||
role="user",
|
||||
)
|
||||
admin_token = login(admin["username"], "tenant-admin-demo-password")["token"]
|
||||
user_token = login(user["username"], "plain-demo-gate-password")["token"]
|
||||
|
||||
for token in (admin_token, user_token):
|
||||
response = client.post(
|
||||
"/api/admin/demo-accounts",
|
||||
json={"email": "blocked-demo@example.com"},
|
||||
headers=_headers(token),
|
||||
)
|
||||
assert response.status_code == 403, response.get_json()
|
||||
|
||||
extra_field = client.post(
|
||||
"/api/admin/demo-accounts",
|
||||
json={"email": "strict-demo@example.com", "name": "ignored"},
|
||||
headers=_headers(super_token),
|
||||
)
|
||||
assert extra_field.status_code == 400, extra_field.get_json()
|
||||
|
||||
generic = client.post(
|
||||
"/api/admin/users",
|
||||
json={
|
||||
"username": "generic-demo-user",
|
||||
"password": "generic-demo-password",
|
||||
"role": "demo",
|
||||
},
|
||||
headers=_headers(super_token),
|
||||
)
|
||||
assert generic.status_code == 403, generic.get_json()
|
||||
with pytest.raises(AuthError, match="dedicated provisioning"):
|
||||
user_store.create_user(
|
||||
org_id=Config.DEMO_ORG_ID,
|
||||
username="service-generic-demo",
|
||||
password="service-generic-password",
|
||||
name="Service Generic Demo",
|
||||
role="demo",
|
||||
)
|
||||
|
||||
|
||||
def test_demo_creation_rejects_duplicate_email(client, user_store, login):
|
||||
token = _super_admin_token(user_store, login)
|
||||
first = client.post(
|
||||
"/api/admin/demo-accounts",
|
||||
json={"email": "Duplicate-Demo@example.com"},
|
||||
headers=_headers(token),
|
||||
)
|
||||
second = client.post(
|
||||
"/api/admin/demo-accounts",
|
||||
json={"email": "duplicate-demo@example.com"},
|
||||
headers=_headers(token),
|
||||
)
|
||||
|
||||
assert first.status_code == 201, first.get_json()
|
||||
assert second.status_code == 409, second.get_json()
|
||||
assert len(
|
||||
[u for u in user_store.users.all() if u.get("email") == "duplicate-demo@example.com"]
|
||||
) == 1
|
||||
|
||||
|
||||
def test_demo_creation_delivery_is_mocked_and_plaintext_password_is_never_persisted(
|
||||
client, user_store, login, tmp_path, monkeypatch, caplog
|
||||
):
|
||||
token = _super_admin_token(user_store, login)
|
||||
delivery = client.application.extensions["demo_delivery"]
|
||||
temporary_password = "one-time-demo-password-never-persisted"
|
||||
monkeypatch.setattr(delivery, "generate_password", lambda: temporary_password)
|
||||
monkeypatch.setattr(
|
||||
delivery,
|
||||
"_send_email",
|
||||
lambda **kwargs: {"status": "mocked", "provider": "test", "attempts": 1},
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
delivery,
|
||||
"_send_webhook",
|
||||
lambda **kwargs: {"status": "mocked", "provider": "test", "attempts": 1},
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
"/api/admin/demo-accounts",
|
||||
json={"email": "delivery-safe@example.com"},
|
||||
headers=_headers(token),
|
||||
)
|
||||
|
||||
assert response.status_code == 201, response.get_json()
|
||||
assert response.get_json()["temporary_password"] == temporary_password
|
||||
body = response.get_json()
|
||||
username = body["username"]
|
||||
stored = user_store.get_user(username)
|
||||
assert temporary_password not in stored["password_hash"]
|
||||
|
||||
persisted_text = []
|
||||
for path in tmp_path.rglob("*"):
|
||||
if path.is_file():
|
||||
persisted_text.append(path.read_text(encoding="utf-8", errors="ignore"))
|
||||
all_persisted = "\n".join(persisted_text)
|
||||
assert temporary_password not in all_persisted
|
||||
assert "temporary_password" not in all_persisted
|
||||
assert temporary_password not in "\n".join(record.getMessage() for record in caplog.records)
|
||||
|
||||
events = client.application.extensions["demo_delivery"].events.all()
|
||||
assert len(events) == 1
|
||||
assert temporary_password not in json.dumps(events[0], ensure_ascii=False)
|
||||
|
||||
|
||||
def test_demo_first_login_starts_one_exact_30_day_trial_and_is_idempotent(
|
||||
client, user_store, login, monkeypatch
|
||||
):
|
||||
token = _super_admin_token(user_store, login)
|
||||
delivery = client.application.extensions["demo_delivery"]
|
||||
temporary_password = "first-login-demo-password"
|
||||
monkeypatch.setattr(delivery, "generate_password", lambda: temporary_password)
|
||||
monkeypatch.setattr(
|
||||
delivery,
|
||||
"deliver",
|
||||
lambda **kwargs: {
|
||||
"event_id": "test-event",
|
||||
"email": {"status": "mocked", "attempts": 1},
|
||||
"webhook": {"status": "mocked", "attempts": 1},
|
||||
},
|
||||
)
|
||||
monkeypatch.setattr(users_module, "utc_now", lambda: FIXED_NOW)
|
||||
|
||||
created = client.post(
|
||||
"/api/admin/demo-accounts",
|
||||
json={"email": "trial-clock@example.com"},
|
||||
headers=_headers(token),
|
||||
)
|
||||
assert created.status_code == 201, created.get_json()
|
||||
username = created.get_json()["username"]
|
||||
assert created.get_json()["user"]["trial_started_at"] is None
|
||||
assert created.get_json()["user"]["trial_expires_at"] is None
|
||||
|
||||
first_login = client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": username, "password": temporary_password},
|
||||
)
|
||||
second_login = client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": username, "password": temporary_password},
|
||||
)
|
||||
assert first_login.status_code == 200, first_login.get_json()
|
||||
assert second_login.status_code == 200, second_login.get_json()
|
||||
|
||||
persisted = user_store.get_user(username)
|
||||
started = dt.datetime.fromisoformat(persisted["trial_started_at"])
|
||||
expires = dt.datetime.fromisoformat(persisted["trial_expires_at"])
|
||||
assert started == FIXED_NOW
|
||||
assert expires - started == dt.timedelta(days=30)
|
||||
assert second_login.get_json()["user"]["trial_started_at"] == persisted["trial_started_at"]
|
||||
assert second_login.get_json()["user"]["trial_expires_at"] == persisted["trial_expires_at"]
|
||||
|
||||
|
||||
def test_malformed_or_expired_demo_state_rejects_login_and_existing_tokens(
|
||||
client, user_store, monkeypatch
|
||||
):
|
||||
monkeypatch.setattr(users_module, "utc_now", lambda: FIXED_NOW)
|
||||
malformed, malformed_token = _active_demo_token(
|
||||
user_store,
|
||||
username="malformed-demo-state",
|
||||
email="malformed-demo-state@example.com",
|
||||
)
|
||||
user_store.users.update(
|
||||
malformed["username"],
|
||||
trial_started_at="not-a-timestamp",
|
||||
trial_expires_at=None,
|
||||
)
|
||||
malformed_me = client.get("/api/auth/me", headers=_headers(malformed_token))
|
||||
malformed_login = client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": malformed["username"], "password": "demo-original-password"},
|
||||
)
|
||||
assert malformed_me.status_code == 401, malformed_me.get_json()
|
||||
assert malformed_login.status_code == 401, malformed_login.get_json()
|
||||
|
||||
expired, expired_token = _active_demo_token(
|
||||
user_store,
|
||||
username="expired-demo-state",
|
||||
email="expired-demo-state@example.com",
|
||||
)
|
||||
expired_started = FIXED_NOW - dt.timedelta(days=31)
|
||||
expired_expires = FIXED_NOW - dt.timedelta(days=1)
|
||||
user_store.users.update(
|
||||
expired["username"],
|
||||
trial_started_at=expired_started.isoformat(),
|
||||
trial_expires_at=expired_expires.isoformat(),
|
||||
)
|
||||
expired_me = client.get("/api/auth/me", headers=_headers(expired_token))
|
||||
expired_login = client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": expired["username"], "password": "demo-original-password"},
|
||||
)
|
||||
assert expired_me.status_code == 401, expired_me.get_json()
|
||||
assert expired_login.status_code == 401, expired_login.get_json()
|
||||
|
||||
|
||||
def test_revoke_demo_invalidates_an_existing_token(client, user_store, login):
|
||||
super_token = _super_admin_token(user_store, login)
|
||||
demo, demo_token = _active_demo_token(
|
||||
user_store,
|
||||
username="revoked-demo-account",
|
||||
email="revoked-demo-account@example.com",
|
||||
)
|
||||
before_version = demo["auth_version"]
|
||||
|
||||
revoked = client.post(
|
||||
f"/api/admin/demo-accounts/{demo['username']}/revoke",
|
||||
headers=_headers(super_token),
|
||||
)
|
||||
assert revoked.status_code == 200, revoked.get_json()
|
||||
updated = user_store.get_user(demo["username"])
|
||||
assert updated["active"] is False
|
||||
assert updated["auth_version"] == before_version + 1
|
||||
assert revoked.get_json()["revoked"] is True
|
||||
|
||||
existing_token_response = client.get(
|
||||
"/api/auth/me", headers=_headers(demo_token)
|
||||
)
|
||||
assert existing_token_response.status_code == 401, existing_token_response.get_json()
|
||||
login_after_revoke = client.post(
|
||||
"/api/auth/login",
|
||||
json={"username": demo["username"], "password": "demo-original-password"},
|
||||
)
|
||||
assert login_after_revoke.status_code == 401, login_after_revoke.get_json()
|
||||
|
||||
|
||||
def test_demo_tenant_visibility_and_role_mutation_gates(client, user_store, login):
|
||||
super_token = _super_admin_token(user_store, login)
|
||||
admin = user_store.create_user(
|
||||
org_id="org-default",
|
||||
username="visibility-admin",
|
||||
password="visibility-admin-password",
|
||||
name="Visibility Admin",
|
||||
role="admin",
|
||||
)
|
||||
user = user_store.create_user(
|
||||
org_id="org-default",
|
||||
username="visibility-user",
|
||||
password="visibility-user-password",
|
||||
name="Visibility User",
|
||||
role="user",
|
||||
)
|
||||
admin_token = login(admin["username"], "visibility-admin-password")["token"]
|
||||
user_token = login(user["username"], "visibility-user-password")["token"]
|
||||
_demo, demo_token = _active_demo_token(
|
||||
user_store,
|
||||
username="visibility-demo",
|
||||
email="visibility-demo@example.com",
|
||||
)
|
||||
|
||||
public_gid, public_pid = _ready_group(
|
||||
client.application,
|
||||
org_id="org-default",
|
||||
creator_id="admin",
|
||||
visibility="public",
|
||||
suffix="public",
|
||||
)
|
||||
hidden_gid, hidden_pid = _ready_group(
|
||||
client.application,
|
||||
org_id="org-default",
|
||||
creator_id="admin",
|
||||
visibility="hidden",
|
||||
suffix="hidden",
|
||||
)
|
||||
demo_gid, demo_pid = _ready_group(
|
||||
client.application,
|
||||
org_id=Config.DEMO_ORG_ID,
|
||||
creator_id="admin",
|
||||
visibility="demo",
|
||||
suffix="demo",
|
||||
)
|
||||
|
||||
user_groups = client.get("/api/groups", headers=_headers(user_token)).get_json()["groups"]
|
||||
admin_groups = client.get("/api/groups", headers=_headers(admin_token)).get_json()["groups"]
|
||||
demo_groups = client.get("/api/groups", headers=_headers(demo_token)).get_json()["groups"]
|
||||
assert public_gid in {g["id"] for g in user_groups}
|
||||
assert hidden_gid not in {g["id"] for g in user_groups}
|
||||
assert demo_gid not in {g["id"] for g in user_groups}
|
||||
assert {public_gid, hidden_gid} <= {g["id"] for g in admin_groups}
|
||||
assert demo_gid not in {g["id"] for g in admin_groups}
|
||||
assert {demo_gid} == {g["id"] for g in demo_groups}
|
||||
|
||||
assert client.get(f"/api/groups/{public_gid}", headers=_headers(user_token)).status_code == 200
|
||||
assert client.get(f"/api/groups/{hidden_gid}", headers=_headers(user_token)).status_code == 403
|
||||
assert client.get(f"/api/groups/{demo_gid}", headers=_headers(user_token)).status_code == 404
|
||||
assert client.get(f"/api/groups/{public_gid}", headers=_headers(demo_token)).status_code == 404
|
||||
assert client.get(f"/api/groups/{demo_gid}", headers=_headers(demo_token)).status_code == 200
|
||||
|
||||
demo_create = client.post(
|
||||
"/api/groups",
|
||||
json={"product": "Demo must not create"},
|
||||
headers=_headers(demo_token),
|
||||
)
|
||||
demo_persona = client.post(
|
||||
"/api/me/personas/generate",
|
||||
json={"mode": "manual", "spec": {}},
|
||||
headers=_headers(demo_token),
|
||||
)
|
||||
user_create = client.post(
|
||||
"/api/groups",
|
||||
json={"product": "User private product"},
|
||||
headers=_headers(user_token),
|
||||
)
|
||||
admin_create = client.post(
|
||||
"/api/groups",
|
||||
json={"product": "Admin shared product", "visibility": "hidden"},
|
||||
headers=_headers(admin_token),
|
||||
)
|
||||
assert demo_create.status_code == 403, demo_create.get_json()
|
||||
assert demo_persona.status_code == 403, demo_persona.get_json()
|
||||
assert user_create.status_code == 201, user_create.get_json()
|
||||
assert user_create.get_json()["group"]["is_owned"] is True
|
||||
assert "owner_user_id" not in user_create.get_json()["group"]
|
||||
assert admin_create.status_code == 201, admin_create.get_json()
|
||||
assert "owner_user_id" not in admin_create.get_json()["group"]
|
||||
|
||||
user_patch = client.patch(
|
||||
f"/api/groups/{hidden_gid}/visibility",
|
||||
json={"visibility": "public"},
|
||||
headers=_headers(user_token),
|
||||
)
|
||||
demo_patch = client.patch(
|
||||
f"/api/groups/{demo_gid}/visibility",
|
||||
json={"visibility": "public"},
|
||||
headers=_headers(demo_token),
|
||||
)
|
||||
admin_patch = client.patch(
|
||||
f"/api/groups/{hidden_gid}/visibility",
|
||||
json={"visibility": "public"},
|
||||
headers=_headers(admin_token),
|
||||
)
|
||||
assert user_patch.status_code == 403, user_patch.get_json()
|
||||
assert demo_patch.status_code == 403, demo_patch.get_json()
|
||||
assert admin_patch.status_code == 200, admin_patch.get_json()
|
||||
assert user_store is not None
|
||||
assert client.application.extensions["group_store"].get(hidden_gid)["visibility"] == "public"
|
||||
|
||||
|
||||
def test_admin_hidden_group_preview_survives_ready_group_authorization(client, user_store, login):
|
||||
super_token = _super_admin_token(user_store, login)
|
||||
admin = user_store.create_user(
|
||||
org_id="org-default",
|
||||
username="hidden-preview-admin",
|
||||
password="hidden-preview-admin-password",
|
||||
name="Hidden Preview Admin",
|
||||
role="admin",
|
||||
)
|
||||
user = user_store.create_user(
|
||||
org_id="org-default",
|
||||
username="hidden-preview-user",
|
||||
password="hidden-preview-user-password",
|
||||
name="Hidden Preview User",
|
||||
role="user",
|
||||
)
|
||||
admin_token = login(admin["username"], "hidden-preview-admin-password")["token"]
|
||||
user_token = login(user["username"], "hidden-preview-user-password")["token"]
|
||||
gid, pid = _ready_group(
|
||||
client.application,
|
||||
org_id="org-default",
|
||||
creator_id="admin",
|
||||
visibility="hidden",
|
||||
suffix="preview",
|
||||
)
|
||||
client.application.extensions["llm"] = MockLLM()
|
||||
|
||||
admin_start = client.post(
|
||||
f"/api/chat/{gid}/personas/{pid}/chat/start",
|
||||
json={"mode": "preview", "scenario": "social"},
|
||||
headers=_headers(admin_token),
|
||||
)
|
||||
user_start = client.post(
|
||||
f"/api/chat/{gid}/personas/{pid}/chat/start",
|
||||
json={"mode": "trainee", "scenario": "social"},
|
||||
headers=_headers(user_token),
|
||||
)
|
||||
assert admin_start.status_code == 200, admin_start.get_json()
|
||||
assert admin_start.get_json()["session"]["mode"] == "preview"
|
||||
assert user_start.status_code == 403, user_start.get_json()
|
||||
|
||||
admin_send = client.post(
|
||||
f"/api/chat/{gid}/personas/{pid}/chat/send",
|
||||
json={"text": "Please tell me more."},
|
||||
headers=_headers(admin_token),
|
||||
)
|
||||
assert admin_send.status_code == 200, admin_send.get_json()
|
||||
assert admin_send.get_json()["session"]["mode"] == "preview"
|
||||
assert admin_send.get_json()["finished"] is True
|
||||
assert super_token
|
||||
|
||||
|
||||
def test_demo_can_train_and_read_own_progress_but_cannot_manage_or_analyze(
|
||||
client, user_store
|
||||
):
|
||||
_demo, demo_token = _active_demo_token(
|
||||
user_store,
|
||||
username="demo-permission-matrix",
|
||||
email="demo-permission-matrix@example.com",
|
||||
)
|
||||
gid, pid = _ready_group(
|
||||
client.application,
|
||||
org_id=Config.DEMO_ORG_ID,
|
||||
creator_id="demo-permission-matrix",
|
||||
visibility="demo",
|
||||
suffix="permission-matrix",
|
||||
)
|
||||
client.application.extensions["llm"] = MockLLM()
|
||||
headers = _headers(demo_token)
|
||||
|
||||
started = client.post(
|
||||
f"/api/chat/{gid}/personas/{pid}/chat/start",
|
||||
json={"mode": "trainee", "scenario": "social", "locale": "en"},
|
||||
headers=headers,
|
||||
)
|
||||
assert started.status_code == 200, started.get_json()
|
||||
session_id = started.get_json()["session"]["id"]
|
||||
sent = client.post(
|
||||
f"/api/chat/{gid}/personas/{pid}/chat/send",
|
||||
json={"text": "Tell me more about the price."},
|
||||
headers=headers,
|
||||
)
|
||||
assert sent.status_code == 200, sent.get_json()
|
||||
|
||||
board = client.get("/api/me/board", headers=headers)
|
||||
sessions = client.get("/api/chat/sessions", headers=headers)
|
||||
session = client.get(f"/api/chat/sessions/{session_id}", headers=headers)
|
||||
weak_areas = client.get("/api/me/weak-areas", headers=headers)
|
||||
assert board.status_code == 200, board.get_json()
|
||||
assert gid in {item["group_id"] for item in board.get_json()["board"]}
|
||||
assert sessions.status_code == 200, sessions.get_json()
|
||||
assert session_id in {item["id"] for item in sessions.get_json()["sessions"]}
|
||||
assert session.status_code == 200, session.get_json()
|
||||
assert session.get_json()["session"]["id"] == session_id
|
||||
assert weak_areas.status_code == 200, weak_areas.get_json()
|
||||
assert "insight" in weak_areas.get_json()
|
||||
|
||||
forbidden = (
|
||||
("post", f"/api/groups/{gid}/personas/{pid}/variant", {}),
|
||||
("put", f"/api/groups/{gid}/personas/{pid}", {"name": "blocked"}),
|
||||
("post", f"/api/groups/{gid}/analyze", None),
|
||||
("post", f"/api/groups/{gid}/reanalyze", None),
|
||||
("delete", f"/api/groups/{gid}", None),
|
||||
("get", f"/api/groups/{gid}/report", None),
|
||||
("get", "/api/analytics", None),
|
||||
("get", "/api/analytics/export/token", None),
|
||||
)
|
||||
for method, path, payload in forbidden:
|
||||
kwargs = {"headers": headers}
|
||||
if payload is not None:
|
||||
kwargs["json"] = payload
|
||||
response = getattr(client, method)(path, **kwargs)
|
||||
assert response.status_code == 403, response.get_json()
|
||||
|
||||
|
||||
def test_demo_webhook_redirects_never_downgrade_or_cross_origin():
|
||||
handler = _HttpsOnlyRedirectHandler()
|
||||
request = Request("https://hooks.example.test/v1/demo", data=b"secret-body")
|
||||
for location in (
|
||||
"http://hooks.example.test/v1/next",
|
||||
"https://attacker.example.test/collect",
|
||||
"https://hooks.example.test/v1/next#fragment",
|
||||
"https://user:password@hooks.example.test/v1/next",
|
||||
):
|
||||
with pytest.raises(URLError):
|
||||
handler.redirect_request(request, None, 302, "Found", {}, location)
|
||||
|
||||
redirected = handler.redirect_request(
|
||||
request, None, 302, "Found", {}, "https://hooks.example.test/v1/next"
|
||||
)
|
||||
assert redirected is not None
|
||||
assert redirected.full_url == "https://hooks.example.test/v1/next"
|
||||
|
||||
|
||||
def test_demo_route_registration_includes_client_visibility_alias(app):
|
||||
assert any(
|
||||
rule.rule == "/api/groups/<gid>/visibility" and "PATCH" in rule.methods
|
||||
for rule in app.url_map.iter_rules()
|
||||
)
|
||||
assert any(
|
||||
rule.rule == "/api/admin/demo-accounts" and "POST" in rule.methods
|
||||
for rule in app.url_map.iter_rules()
|
||||
)
|
||||
assert any(
|
||||
rule.rule == "/api/admin/demo-accounts/<username>/revoke" and "POST" in rule.methods
|
||||
for rule in app.url_map.iter_rules()
|
||||
)
|
||||
@@ -34,6 +34,9 @@ class StubSim:
|
||||
}
|
||||
self.error = error
|
||||
self.judge_calls = 0
|
||||
self.public_debrief_calls = 0
|
||||
self.public_verdict: dict | None = None
|
||||
self.public_error = False
|
||||
|
||||
def judge(self, **kwargs):
|
||||
self.judge_calls += 1
|
||||
@@ -41,6 +44,17 @@ class StubSim:
|
||||
raise LLMError("provider unavailable")
|
||||
return deepcopy(self.verdict)
|
||||
|
||||
def public_debrief(self, **kwargs):
|
||||
self.public_debrief_calls += 1
|
||||
if self.public_error:
|
||||
raise LLMError("public debrief unavailable")
|
||||
source = self.public_verdict if self.public_verdict is not None else self.verdict
|
||||
return {
|
||||
key: deepcopy(source.get(key))
|
||||
for key in ("why", "failurePoints", "coaching")
|
||||
if key in source
|
||||
}
|
||||
|
||||
|
||||
def _session():
|
||||
return {
|
||||
@@ -78,6 +92,7 @@ def test_final_judge_supplies_score_and_debrief_for_automatic_buy(monkeypatch):
|
||||
sessions, sim, updated, debrief = _run(monkeypatch)
|
||||
|
||||
assert sim.judge_calls == 1
|
||||
assert sim.public_debrief_calls == 1
|
||||
assert updated["status"] == "finished"
|
||||
assert updated["outcome"] == "won"
|
||||
assert debrief["score"] == 88
|
||||
@@ -89,6 +104,140 @@ def test_final_judge_supplies_score_and_debrief_for_automatic_buy(monkeypatch):
|
||||
assert sessions.updates[0]["messages"]
|
||||
|
||||
|
||||
def test_public_debrief_does_not_use_hidden_judge_prose(monkeypatch):
|
||||
sessions = FakeSessions(_session())
|
||||
sim = StubSim(
|
||||
verdict={
|
||||
"outcome": "won",
|
||||
"score": 88,
|
||||
"why": "secret opener and hidden budget were exposed",
|
||||
"failurePoints": ["secret pain and secret objection"],
|
||||
"coaching": ["secret formula: use the hidden lever"],
|
||||
}
|
||||
)
|
||||
sim.public_verdict = {
|
||||
"why": "The close was concise and direct",
|
||||
"failurePoints": [],
|
||||
"coaching": ["Keep the close concise"],
|
||||
}
|
||||
monkeypatch.setattr(chat_routes, "_sim", lambda group, persona: sim)
|
||||
|
||||
_updated, debrief = chat_routes._finalize_session(
|
||||
{"sessions": sessions},
|
||||
sessions.row,
|
||||
{"sales_kit": {}},
|
||||
{
|
||||
"name": "Customer",
|
||||
"opener": "secret opener",
|
||||
"budget": "secret budget",
|
||||
"pains": [{"description": "secret pain"}],
|
||||
"negotiation_levers": ["secret lever"],
|
||||
},
|
||||
)
|
||||
|
||||
assert debrief["why"] == "The close was concise and direct"
|
||||
assert debrief["coaching"] == ["Keep the close concise"]
|
||||
assert "secret opener" not in str(debrief)
|
||||
assert "secret budget" not in str(debrief)
|
||||
assert "secret pain" not in str(debrief)
|
||||
assert "secret formula" not in str(debrief)
|
||||
|
||||
|
||||
def test_public_debrief_failure_never_falls_back_to_hidden_judge_prose(monkeypatch):
|
||||
sessions = FakeSessions(_session())
|
||||
sim = StubSim(
|
||||
verdict={
|
||||
"outcome": "lost",
|
||||
"score": 10,
|
||||
"why": "secret hidden persona explanation",
|
||||
"failurePoints": ["secret pain"],
|
||||
"coaching": ["secret coaching formula"],
|
||||
}
|
||||
)
|
||||
sim.public_error = True
|
||||
monkeypatch.setattr(chat_routes, "_sim", lambda group, persona: sim)
|
||||
|
||||
_updated, debrief = chat_routes._finalize_session(
|
||||
{"sessions": sessions},
|
||||
sessions.row,
|
||||
{"sales_kit": {}},
|
||||
{"name": "Customer", "pains": [{"description": "secret pain"}]},
|
||||
)
|
||||
|
||||
assert debrief["why"] == ""
|
||||
assert debrief["failurePoints"] == []
|
||||
assert debrief["coaching"] == []
|
||||
assert "secret hidden persona explanation" not in str(debrief)
|
||||
assert "secret pain" not in str(debrief)
|
||||
|
||||
|
||||
def test_debrief_never_returns_secret_persona_fields(monkeypatch):
|
||||
sessions = FakeSessions(_session())
|
||||
sim = StubSim()
|
||||
monkeypatch.setattr(chat_routes, "_sim", lambda group, persona: sim)
|
||||
persona = {
|
||||
"name": "Customer",
|
||||
"tier": "B",
|
||||
"initiation_mode": "customer",
|
||||
"channel": "line",
|
||||
"profession": "Consultant",
|
||||
"age_group": "30s",
|
||||
"location": "Bangkok",
|
||||
"product_context": "Sales software",
|
||||
"pains": [{"description": "hidden pain"}],
|
||||
"objections": ["hidden objection"],
|
||||
"negotiation_levers": ["hidden lever"],
|
||||
"opener": "secret opener",
|
||||
"rootCause": "secret root cause",
|
||||
"resolutionConditions": ["secret condition"],
|
||||
"tolerance": 1,
|
||||
"income": "secret income",
|
||||
"lifestyle": "secret lifestyle",
|
||||
"personality": "secret personality",
|
||||
"communication_style": "secret communication style",
|
||||
"budget": "secret budget",
|
||||
"decision_timeline": "secret timeline",
|
||||
"goal": "secret goal",
|
||||
"background": "secret background",
|
||||
}
|
||||
|
||||
_updated, debrief = chat_routes._finalize_session(
|
||||
{"sessions": sessions},
|
||||
sessions.row,
|
||||
{"sales_kit": {}},
|
||||
persona,
|
||||
)
|
||||
|
||||
revealed = debrief["revealed_persona"]
|
||||
assert set(revealed) == {
|
||||
"name",
|
||||
"tier",
|
||||
"initiation_mode",
|
||||
"channel",
|
||||
"profession",
|
||||
"age_group",
|
||||
"location",
|
||||
"product_context",
|
||||
}
|
||||
assert not {
|
||||
"pains",
|
||||
"objections",
|
||||
"negotiation_levers",
|
||||
"opener",
|
||||
"rootCause",
|
||||
"resolutionConditions",
|
||||
"tolerance",
|
||||
"income",
|
||||
"lifestyle",
|
||||
"personality",
|
||||
"communication_style",
|
||||
"budget",
|
||||
"decision_timeline",
|
||||
"goal",
|
||||
"background",
|
||||
}.intersection(revealed)
|
||||
|
||||
|
||||
def test_final_judge_supplies_lost_outcome_for_automatic_walk(monkeypatch):
|
||||
sessions, sim, updated, debrief = _run(
|
||||
monkeypatch,
|
||||
@@ -109,6 +258,22 @@ def test_final_judge_supplies_lost_outcome_for_automatic_walk(monkeypatch):
|
||||
assert debrief["failurePoints"] == ["no discovery"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("score", [float("inf"), float("-inf")])
|
||||
def test_nonfinite_final_judge_score_fails_closed(monkeypatch, score):
|
||||
_sessions, _sim, _updated, debrief = _run(
|
||||
monkeypatch,
|
||||
verdict={
|
||||
"outcome": "won",
|
||||
"score": score,
|
||||
"why": "accepted",
|
||||
"failurePoints": [],
|
||||
"coaching": [],
|
||||
},
|
||||
)
|
||||
|
||||
assert debrief["score"] == 0
|
||||
|
||||
|
||||
def test_manual_finish_uses_the_same_helper_and_score(monkeypatch):
|
||||
sessions, sim, updated, debrief = _run(
|
||||
monkeypatch,
|
||||
@@ -148,6 +313,24 @@ def test_judge_failure_persists_transcript_but_keeps_session_retryable(monkeypat
|
||||
assert sessions.updates[0]["messages"] == session["messages"]
|
||||
|
||||
|
||||
def test_malformed_final_judge_result_keeps_session_retryable(monkeypatch):
|
||||
sessions = FakeSessions(_session())
|
||||
sim = StubSim()
|
||||
monkeypatch.setattr(sim, "judge", lambda **_kwargs: ["not", "a", "verdict"])
|
||||
monkeypatch.setattr(chat_routes, "_sim", lambda group, persona: sim)
|
||||
|
||||
with pytest.raises(LLMError):
|
||||
chat_routes._finalize_session(
|
||||
{"sessions": sessions},
|
||||
sessions.row,
|
||||
{"sales_kit": {}},
|
||||
{"name": "Customer", "pains": []},
|
||||
)
|
||||
|
||||
assert sessions.row["status"] == "active"
|
||||
assert sessions.row["outcome"] is None
|
||||
|
||||
|
||||
def test_finalization_is_idempotent_without_a_second_judge(monkeypatch):
|
||||
session = _session()
|
||||
session.update(
|
||||
|
||||
@@ -155,7 +155,16 @@ def test_analytics_hardest_personas_prioritize_losses_then_low_score(
|
||||
)
|
||||
session_store = client.application.extensions["session_store"]
|
||||
analytics_group = client.application.extensions["group_store"].create(
|
||||
org_id="org-default", creator_id="admin", title="Analytics group"
|
||||
org_id="org-default",
|
||||
creator_id="admin",
|
||||
title="Analytics group",
|
||||
status="ready",
|
||||
sales_kit={"productName": "Test product"},
|
||||
report={"summary": "Test report"},
|
||||
personas=[
|
||||
{"id": "hard-persona", "name": "Hard persona"},
|
||||
{"id": "easy-persona", "name": "Easy persona"},
|
||||
],
|
||||
)
|
||||
rows = [
|
||||
("hard-1", "Hard persona", "lost", 20),
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
"""Regression tests for group response redaction."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.groups import GroupStore
|
||||
from scripts.mock_llm import MockLLM
|
||||
|
||||
@@ -33,6 +35,43 @@ def _ready_group(client, token):
|
||||
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)
|
||||
|
||||
@@ -55,8 +94,12 @@ def test_admin_analyze_get_and_list_use_same_redaction(client, user_store, login
|
||||
|
||||
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
|
||||
@@ -99,6 +142,30 @@ def test_admin_response_does_not_expose_future_secret_fields(
|
||||
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
|
||||
):
|
||||
@@ -243,6 +310,8 @@ def test_user_group_index_is_owner_scoped_and_revealable_only(tmp_path):
|
||||
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"}],
|
||||
)
|
||||
|
||||
@@ -273,6 +342,121 @@ def test_user_group_index_is_owner_scoped_and_revealable_only(tmp_path):
|
||||
]
|
||||
|
||||
|
||||
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)
|
||||
|
||||
@@ -80,4 +80,4 @@ def test_report_is_tenant_and_role_guarded(client, user_store, login):
|
||||
must_setup=False,
|
||||
)
|
||||
other_token = login(other_admin["username"], "other-report-password")["token"]
|
||||
assert client.get(f"/api/groups/{gid}/report", headers=_headers(other_token)).status_code == 403
|
||||
assert client.get(f"/api/groups/{gid}/report", headers=_headers(other_token)).status_code == 404
|
||||
|
||||
@@ -12,10 +12,10 @@ import jwt
|
||||
import pytest
|
||||
from flask import g
|
||||
|
||||
from app.api.analytics_routes import _csv_cell
|
||||
from app.api.analytics_routes import _csv_cell, _is_shared_group, _session_matches_context
|
||||
from app.api.helpers import ApiError, current_org_id, require_roles
|
||||
from app.auth.users import AuthError, is_valid_tenant_id, normalize_identifier
|
||||
from app.config import Config
|
||||
from app.config import Config, _get_int
|
||||
from app.services.groups import GroupStore
|
||||
from app.services.file_parser import ParseError, parse_pdf
|
||||
from app.services import rate_limit
|
||||
@@ -47,8 +47,21 @@ def test_group_indexes_hide_private_groups_from_tenant_admins_and_fail_closed(tm
|
||||
store = GroupStore(tmp_path)
|
||||
shared = store.create(org_id="org-1", creator_id="admin-1", title="Shared")
|
||||
private = store.create(org_id="org-1", creator_id="user-1", title="Private")
|
||||
store.update(shared["id"], status="ready")
|
||||
store.update(private["id"], status="ready", owner_user_id="user-1")
|
||||
store.update(
|
||||
shared["id"],
|
||||
status="ready",
|
||||
sales_kit={"productName": "Test product"},
|
||||
report={"summary": "Test report"},
|
||||
personas=[_ready_persona()],
|
||||
)
|
||||
store.update(
|
||||
private["id"],
|
||||
status="ready",
|
||||
owner_user_id="user-1",
|
||||
sales_kit={"productName": "Test product"},
|
||||
report={"summary": "Test report"},
|
||||
personas=[{"id": "private-persona", "name": "Private Persona"}],
|
||||
)
|
||||
|
||||
visible = store.list_visible_to(role="admin", org_id="org-1", user_id="admin-1")
|
||||
assert [group["id"] for group in visible] == [shared["id"]]
|
||||
@@ -56,14 +69,92 @@ def test_group_indexes_hide_private_groups_from_tenant_admins_and_fail_closed(tm
|
||||
assert store.list_for_org("") == []
|
||||
|
||||
|
||||
def test_ready_group_persona_update_rejects_corrupted_ready_artifacts(tmp_path: Path):
|
||||
store = GroupStore(tmp_path)
|
||||
group = store.create(org_id="org-1", creator_id="admin-1", title="Shared")
|
||||
store.update(
|
||||
group["id"],
|
||||
status="ready",
|
||||
sales_kit={"productName": "Test product"},
|
||||
report={"summary": "Test report"},
|
||||
personas=[_ready_persona()],
|
||||
)
|
||||
store.groups.update(group["id"], report={})
|
||||
|
||||
with pytest.raises(ValueError, match="ready group"):
|
||||
store.update_persona(group["id"], "p-1", {"name": "Updated"})
|
||||
|
||||
|
||||
def test_analytics_context_rejects_blank_group_and_tenant_ids():
|
||||
ready = {
|
||||
"id": "group-1",
|
||||
"org_id": "org-1",
|
||||
"status": "ready",
|
||||
"visibility": "public",
|
||||
"sales_kit": {"productName": "Test product"},
|
||||
"report": {"summary": "Test report"},
|
||||
"personas": [_ready_persona()],
|
||||
}
|
||||
assert _is_shared_group({**ready, "id": ""}) is False
|
||||
assert _is_shared_group({**ready, "org_id": ""}) is False
|
||||
assert _session_matches_context(
|
||||
{"group_id": "", "org_id": "org-1", "persona_id": "p-1"},
|
||||
{"": ("org-1", {"p-1"})},
|
||||
) is False
|
||||
|
||||
|
||||
def test_group_owner_marker_is_fail_closed_for_falsey_malformed_values(tmp_path: Path):
|
||||
store = GroupStore(tmp_path)
|
||||
group = store.create(org_id="org-1", creator_id="user-1", title="Malformed")
|
||||
store.update(group["id"], status="ready", owner_user_id=0)
|
||||
store.groups.update(group["id"], status="ready", owner_user_id=0)
|
||||
|
||||
assert store.list_visible_to(role="user", org_id="org-1", user_id="user-2") == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad_visibility", [None, "", 0, {}, False, "PUBLIC"])
|
||||
def test_group_visibility_indexes_fail_closed_for_malformed_values(
|
||||
tmp_path: Path, bad_visibility
|
||||
):
|
||||
store = GroupStore(tmp_path)
|
||||
group = store.create(org_id="org-1", creator_id="admin-1", title="Malformed visibility")
|
||||
store.groups.update(group["id"], status="ready", visibility=bad_visibility)
|
||||
|
||||
assert store.list_visible_to(role="user", org_id="org-1", user_id="user-1") == []
|
||||
assert store.list_visible_to(role="admin", org_id="org-1", user_id="admin-1") == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad_visibility, case",
|
||||
[
|
||||
(None, "none"),
|
||||
("", "empty"),
|
||||
(0, "zero"),
|
||||
({}, "object"),
|
||||
(False, "false"),
|
||||
("PUBLIC", "uppercase"),
|
||||
],
|
||||
)
|
||||
def test_id_addressed_malformed_visibility_is_not_public(
|
||||
client, user_store, login, bad_visibility, case
|
||||
):
|
||||
user = user_store.create_user(
|
||||
org_id="org-default",
|
||||
username=f"malformed-visibility-{case}",
|
||||
password="malformed-visibility-password",
|
||||
name="Malformed Visibility Reader",
|
||||
role="user",
|
||||
must_setup=False,
|
||||
)
|
||||
token = login(user["username"], "malformed-visibility-password")["token"]
|
||||
groups = client.application.extensions["group_store"]
|
||||
group = groups.create(org_id="org-default", creator_id="admin", title="Malformed visibility")
|
||||
groups.groups.update(group["id"], status="ready", visibility=bad_visibility)
|
||||
|
||||
response = client.get(f"/api/groups/{group['id']}", headers=_headers(token))
|
||||
|
||||
assert response.status_code == 404, response.get_json()
|
||||
|
||||
|
||||
def test_id_addressed_private_group_with_null_owner_is_not_public(client, user_store, login):
|
||||
user = user_store.create_user(
|
||||
org_id="org-default",
|
||||
@@ -76,7 +167,7 @@ def test_id_addressed_private_group_with_null_owner_is_not_public(client, user_s
|
||||
token = login(user["username"], "null-owner-reader-password")["token"]
|
||||
groups = client.application.extensions["group_store"]
|
||||
group = groups.create(org_id="org-default", creator_id="admin", title="Legacy private")
|
||||
groups.update(group["id"], status="ready", owner_user_id=None)
|
||||
groups.groups.update(group["id"], status="ready", owner_user_id=None)
|
||||
|
||||
response = client.get(f"/api/groups/{group['id']}", headers=_headers(token))
|
||||
|
||||
@@ -105,7 +196,7 @@ def test_tenant_admin_persona_update_rejects_unknown_fields(client, user_store,
|
||||
token = login(admin["username"], "legacy-tenant-admin-password")["token"]
|
||||
groups = client.application.extensions["group_store"]
|
||||
group = groups.create(org_id="org-default", creator_id=admin["id"], title="Editable")
|
||||
groups.update(group["id"], status="ready")
|
||||
groups.update(group["id"], sales_kit={"productName": "Test product"}, report={"summary": "Test report"})
|
||||
groups.set_personas(group["id"], [_ready_persona()])
|
||||
|
||||
response = client.put(
|
||||
@@ -150,15 +241,15 @@ def test_persona_index_handles_malformed_collection(client, user_store, login):
|
||||
token = login("admin", "legacy-persona-list-admin-password")["token"]
|
||||
groups = client.application.extensions["group_store"]
|
||||
group = groups.create(org_id="org-default", creator_id="admin", title="Malformed personas")
|
||||
groups.update(group["id"], status="ready", personas="not-a-list")
|
||||
groups.groups.update(group["id"], status="ready", personas="not-a-list")
|
||||
|
||||
response = client.get(f"/api/groups/{group['id']}/personas", headers=_headers(token))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.get_json()["personas"] == []
|
||||
assert response.status_code == 403
|
||||
assert response.get_json()["error"] == "group not ready"
|
||||
|
||||
|
||||
def test_board_skips_personas_without_ids(client, user_store, login):
|
||||
def test_board_excludes_group_with_malformed_persona_collection(client, user_store, login):
|
||||
user_store.complete_setup(
|
||||
"admin",
|
||||
"legacy-board-persona-admin@example.com",
|
||||
@@ -169,12 +260,12 @@ def test_board_skips_personas_without_ids(client, user_store, login):
|
||||
token = login("admin", "legacy-board-persona-admin-password")["token"]
|
||||
groups = client.application.extensions["group_store"]
|
||||
group = groups.create(org_id="org-default", creator_id="admin", title="Malformed board personas")
|
||||
groups.update(group["id"], status="ready", personas=["not-a-dict", _ready_persona()])
|
||||
groups.groups.update(group["id"], status="ready", personas=["not-a-dict", _ready_persona()])
|
||||
|
||||
response = client.get("/api/me/board", headers=_headers(token))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert [item["persona_id"] for item in response.get_json()["board"]] == ["p-1"]
|
||||
assert response.get_json()["board"] == []
|
||||
|
||||
|
||||
def test_analysis_report_failure_marks_group_failed(client, user_store, login, monkeypatch):
|
||||
@@ -265,6 +356,34 @@ def test_jwt_rejects_non_increasing_temporal_claims(user_store, monkeypatch):
|
||||
user_store.decode_token("opaque-token")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("raw_value", ["0", "-1", "721", "not-an-integer"])
|
||||
def test_jwt_lifetime_config_falls_back_to_bounded_default(monkeypatch, raw_value):
|
||||
monkeypatch.setenv("JWT_EXPIRES_HOURS", raw_value)
|
||||
|
||||
assert _get_int(
|
||||
"JWT_EXPIRES_HOURS",
|
||||
24,
|
||||
minimum=1,
|
||||
maximum=Config.JWT_MAX_EXPIRES_HOURS,
|
||||
) == 24
|
||||
|
||||
|
||||
@pytest.mark.parametrize("configured_hours", [0, -1, Config.JWT_MAX_EXPIRES_HOURS + 1])
|
||||
def test_issue_token_rejects_invalid_jwt_lifetime(user_store, monkeypatch, configured_hours):
|
||||
user = user_store.create_user(
|
||||
org_id="org-default",
|
||||
username=f"invalid-jwt-lifetime-{configured_hours}",
|
||||
password="invalid-jwt-lifetime-password",
|
||||
name="Invalid JWT Lifetime",
|
||||
role="user",
|
||||
must_setup=False,
|
||||
)
|
||||
monkeypatch.setattr(Config, "JWT_EXPIRES_HOURS", configured_hours)
|
||||
|
||||
with pytest.raises(AuthError, match="invalid account state"):
|
||||
user_store.issue_token(user)
|
||||
|
||||
|
||||
def test_noncanonical_legacy_username_cannot_issue_token(user_store):
|
||||
user = user_store.create_user(
|
||||
org_id="org-default",
|
||||
@@ -480,7 +599,15 @@ def test_export_handles_malformed_debrief_without_500(client, user_store, login)
|
||||
)
|
||||
groups = client.application.extensions["group_store"]
|
||||
sessions = client.application.extensions["session_store"]
|
||||
group = groups.create(org_id="org-default", creator_id="admin", title="Export")
|
||||
group = groups.create(
|
||||
org_id="org-default",
|
||||
creator_id="admin",
|
||||
title="Export",
|
||||
status="ready",
|
||||
sales_kit={"productName": "Test product"},
|
||||
report={"summary": "Test report"},
|
||||
personas=[{"id": "p-1", "name": "Export Persona"}],
|
||||
)
|
||||
record = sessions.create(
|
||||
org_id="org-default",
|
||||
user_id=trainee["id"],
|
||||
@@ -542,8 +669,24 @@ def test_non_super_admin_analytics_requires_session_and_group_tenant_scope(
|
||||
user_store.create_org("Other tenant", org_id="org-other")
|
||||
groups = client.application.extensions["group_store"]
|
||||
sessions = client.application.extensions["session_store"]
|
||||
own_group = groups.create(org_id="org-default", creator_id="admin", title="Own")
|
||||
other_group = groups.create(org_id="org-other", creator_id="admin", title="Other")
|
||||
own_group = groups.create(
|
||||
org_id="org-default",
|
||||
creator_id="admin",
|
||||
title="Own",
|
||||
status="ready",
|
||||
sales_kit={"productName": "Test product"},
|
||||
report={"summary": "Test report"},
|
||||
personas=[{"id": "scope-own", "name": "Own"}],
|
||||
)
|
||||
other_group = groups.create(
|
||||
org_id="org-other",
|
||||
creator_id="admin",
|
||||
title="Other",
|
||||
status="ready",
|
||||
sales_kit={"productName": "Test product"},
|
||||
report={"summary": "Test report"},
|
||||
personas=[{"id": "scope-cross-group", "name": "Other"}],
|
||||
)
|
||||
rows = [
|
||||
("scope-own", "org-default", own_group["id"]),
|
||||
("scope-stale-session", "org-other", own_group["id"]),
|
||||
@@ -889,7 +1032,14 @@ def test_analytics_and_export_exclude_private_groups_from_tenant_admin(
|
||||
groups = client.application.extensions["group_store"]
|
||||
sessions = client.application.extensions["session_store"]
|
||||
private = groups.create(org_id="org-default", creator_id=trainee["id"], title="Private")
|
||||
groups.update(private["id"], status="ready", owner_user_id=trainee["id"])
|
||||
groups.update(
|
||||
private["id"],
|
||||
status="ready",
|
||||
owner_user_id=trainee["id"],
|
||||
sales_kit={"productName": "Test product"},
|
||||
report={"summary": "Test report"},
|
||||
personas=[{"id": "private-persona", "name": "Private Persona"}],
|
||||
)
|
||||
sessions.sessions.create(
|
||||
{
|
||||
"id": "private-analytics-session",
|
||||
@@ -914,6 +1064,58 @@ def test_analytics_and_export_exclude_private_groups_from_tenant_admin(
|
||||
assert "Private Persona" not in export_response.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_super_admin_export_excludes_private_groups(
|
||||
client, user_store, login
|
||||
):
|
||||
user_store.complete_setup(
|
||||
"admin",
|
||||
"super-export-admin@example.com",
|
||||
"super-export-admin-password",
|
||||
accepted_terms=True,
|
||||
accepted_terms_at="2026-08-15T00:00:00Z",
|
||||
)
|
||||
trainee = user_store.create_user(
|
||||
org_id="org-default",
|
||||
username="super-export-trainee",
|
||||
password="super-export-trainee-password",
|
||||
name="Super Export Trainee",
|
||||
role="user",
|
||||
must_setup=False,
|
||||
)
|
||||
token = login("admin", "super-export-admin-password")["token"]
|
||||
groups = client.application.extensions["group_store"]
|
||||
sessions = client.application.extensions["session_store"]
|
||||
private = groups.create(org_id="org-default", creator_id=trainee["id"], title="Private")
|
||||
groups.update(
|
||||
private["id"],
|
||||
status="ready",
|
||||
owner_user_id=trainee["id"],
|
||||
sales_kit={"productName": "Private product"},
|
||||
report={"summary": "Private report"},
|
||||
personas=[{"id": "super-private-persona", "name": "Super Private Persona"}],
|
||||
)
|
||||
sessions.sessions.create(
|
||||
{
|
||||
"id": "super-private-session",
|
||||
"org_id": "org-default",
|
||||
"user_id": trainee["id"],
|
||||
"group_id": private["id"],
|
||||
"persona_id": "super-private-persona",
|
||||
"persona_name": "Super Private Persona",
|
||||
"mode": "trainee",
|
||||
"status": "finished",
|
||||
"outcome": "lost",
|
||||
"debrief": {"score": 50},
|
||||
},
|
||||
key="super-private-session",
|
||||
)
|
||||
|
||||
response = client.get("/api/analytics/export", headers=_headers(token))
|
||||
|
||||
assert response.status_code == 200
|
||||
assert "Super Private Persona" not in response.get_data(as_text=True)
|
||||
|
||||
|
||||
def test_analytics_uses_only_finished_won_or_lost_sessions_and_clamps_scores(
|
||||
client, user_store, login
|
||||
):
|
||||
@@ -937,7 +1139,15 @@ def test_analytics_uses_only_finished_won_or_lost_sessions_and_clamps_scores(
|
||||
token = login(admin["username"], "bounded-analytics-admin-password")["token"]
|
||||
groups = client.application.extensions["group_store"]
|
||||
sessions = client.application.extensions["session_store"]
|
||||
group = groups.create(org_id="org-default", creator_id=admin["id"], title="Bounded")
|
||||
group = groups.create(
|
||||
org_id="org-default",
|
||||
creator_id=admin["id"],
|
||||
title="Bounded",
|
||||
status="ready",
|
||||
sales_kit={"productName": "Test product"},
|
||||
report={"summary": "Test report"},
|
||||
personas=[{"id": "bounded-won", "name": "bounded-won"}],
|
||||
)
|
||||
for sid, status, outcome, score in (
|
||||
("bounded-active", "active", "won", 99),
|
||||
("bounded-abandoned", "finished", "abandoned", 99),
|
||||
|
||||
@@ -20,10 +20,12 @@ ENABLED = dict(
|
||||
|
||||
|
||||
@pytest.fixture()
|
||||
def oauth_enabled(monkeypatch):
|
||||
def oauth_enabled(monkeypatch, user_store):
|
||||
"""Turn on both providers + default org on Config for the test."""
|
||||
for name, value in ENABLED.items():
|
||||
monkeypatch.setattr(Config, name, value)
|
||||
if user_store.get_org_or_none("org-public") is None:
|
||||
user_store.create_org("Public Signups", org_id="org-public")
|
||||
yield
|
||||
|
||||
|
||||
@@ -60,27 +62,31 @@ def test_new_google_user_created_in_default_org(client, user_store, oauth_enable
|
||||
response = _post(client)
|
||||
assert response.status_code == 200, response.get_json()
|
||||
body = response.get_json()
|
||||
assert body["must_setup"] is False
|
||||
assert body["must_setup"] is True
|
||||
user = body["user"]
|
||||
assert user["username"] == "g_123456789"
|
||||
assert user["id"] == "g_123456789"
|
||||
assert user["role"] == "user"
|
||||
assert user["email"] == "new.user@gmail.com"
|
||||
assert user["org_id"] == "org-public"
|
||||
assert user["accepted_terms"] is False
|
||||
assert user["accepted_terms_at"] is None
|
||||
assert "password_hash" not in user
|
||||
|
||||
created = user_store.get_user("g_123456789")
|
||||
assert created["role"] == "user"
|
||||
assert created["org_id"] == "org-public"
|
||||
assert created["must_setup"] is True
|
||||
org = user_store.get_org("org-public")
|
||||
assert org["active"] is True
|
||||
# Token is usable against an authenticated endpoint.
|
||||
me = client.get("/api/auth/me", headers={"Authorization": f"Bearer {body['token']}"})
|
||||
assert me.status_code == 200
|
||||
protected = client.get("/api/groups", headers={"Authorization": f"Bearer {body['token']}"})
|
||||
assert protected.status_code == 403, protected.get_json()
|
||||
|
||||
|
||||
def test_email_match_logs_in_existing_user(client, user_store, oauth_enabled, google_ok):
|
||||
user_store.create_org("Public Signups", org_id="org-public")
|
||||
def test_email_match_requires_explicit_account_linking(client, user_store, oauth_enabled, google_ok):
|
||||
existing = user_store.create_user(
|
||||
org_id="org-public",
|
||||
username="existing",
|
||||
@@ -96,13 +102,37 @@ def test_email_match_logs_in_existing_user(client, user_store, oauth_enabled, go
|
||||
oauth_svc.validate_google_token = lambda token: ("match@example.com", "999999", "Existing User")
|
||||
|
||||
response = _post(client)
|
||||
assert response.status_code == 200, response.get_json()
|
||||
body = response.get_json()
|
||||
assert body["user"]["username"] == "existing"
|
||||
assert body["user"]["email"] == "match@example.com"
|
||||
assert response.status_code == 401, response.get_json()
|
||||
assert "token" not in response.get_json()
|
||||
assert user_store.get_user_or_none("g_999999") is None # no duplicate created
|
||||
|
||||
|
||||
def test_email_match_rejects_inactive_organization(client, user_store, oauth_enabled, google_ok):
|
||||
user_store.create_user(
|
||||
org_id="org-public",
|
||||
username="inactive-org-user",
|
||||
password="existing-password",
|
||||
name="Inactive Org User",
|
||||
role="user",
|
||||
email="inactive-org@example.com",
|
||||
must_setup=False,
|
||||
)
|
||||
with user_store.orgs.record_lock("org-public"):
|
||||
user_store.orgs.update("org-public", active=False)
|
||||
|
||||
from app.services import oauth as oauth_svc
|
||||
|
||||
oauth_svc.validate_google_token = lambda token: (
|
||||
"inactive-org@example.com",
|
||||
"999998",
|
||||
"Inactive Org User",
|
||||
)
|
||||
|
||||
response = _post(client)
|
||||
assert response.status_code == 401, response.get_json()
|
||||
assert "token" not in (response.get_json() or {})
|
||||
|
||||
|
||||
def test_invalid_or_unverified_email_token_rejected(client, user_store, oauth_enabled, monkeypatch):
|
||||
from app.services import oauth as oauth_svc
|
||||
|
||||
@@ -124,7 +154,6 @@ def test_disabled_provider_returns_404(client, user_store):
|
||||
|
||||
|
||||
def test_username_collision_gets_unique_suffix(client, user_store, oauth_enabled, google_ok):
|
||||
user_store.create_org("Public Signups", org_id="org-public")
|
||||
user_store.create_user(
|
||||
org_id="org-public",
|
||||
username="g_1",
|
||||
@@ -196,8 +225,6 @@ def test_oauth_requires_provider_and_token(client, oauth_enabled):
|
||||
def test_social_signup_is_seat_checked(client, user_store, oauth_enabled, google_ok):
|
||||
# Default org with a single seat already consumed → new signup must be
|
||||
# rejected (fail closed) once seats are exhausted.
|
||||
user_store.create_org("Public Signups", org_id="org-public")
|
||||
|
||||
# Consume the only seat.
|
||||
user_store.create_user(
|
||||
org_id="org-public",
|
||||
|
||||
@@ -52,6 +52,14 @@ def test_channel_and_initiation_are_retained_in_normalized_persona():
|
||||
validate_persona_traits(persona)
|
||||
|
||||
|
||||
def test_shape_preserves_explicit_invalid_initiation_for_rejection():
|
||||
persona = ensure_persona_shape(_persona(initiation_mode="f2f_call"))
|
||||
|
||||
assert persona["initiation_mode"] == "f2f_call"
|
||||
with pytest.raises(ValueError, match="initiation_mode"):
|
||||
validate_persona_traits(persona)
|
||||
|
||||
|
||||
def test_invalid_channel_is_rejected_not_mapped_to_social():
|
||||
generator = PersonaGenerator(StubPersonaLLM(_fifteen(channel="social")))
|
||||
|
||||
|
||||
@@ -126,6 +126,52 @@ def test_prompt_contains_persona_behavior_contract():
|
||||
assert "tolerance" in system.lower()
|
||||
|
||||
|
||||
def test_prompt_omits_hidden_internal_reason_and_unsafe_transcript_roles():
|
||||
stub = StubLLM([json.dumps({"reply": "ค่ะ"}, ensure_ascii=False)])
|
||||
|
||||
Simulator(stub).persona_reply(
|
||||
persona=_persona(),
|
||||
sales_kit={"productName": "สินค้า"},
|
||||
messages=[
|
||||
{"role": "seller", "text": "ขอถามปัญหาหลักหน่อยครับ"},
|
||||
{"role": "assistant", "text": "hidden assistant transcript"},
|
||||
{"role": "system", "text": "hidden system prompt"},
|
||||
{"role": "system", "text": "⏳ public scene note"},
|
||||
],
|
||||
internal={
|
||||
"turns": 2,
|
||||
"misses": 1,
|
||||
"score": 40,
|
||||
"last_reason": "hidden judge reasoning",
|
||||
"provider_path": "/private/provider/path",
|
||||
},
|
||||
)
|
||||
|
||||
prompt = "\n".join(message["content"] for message in stub.calls[0])
|
||||
assert "hidden judge reasoning" not in prompt
|
||||
assert "/private/provider/path" not in prompt
|
||||
assert "hidden assistant transcript" not in prompt
|
||||
assert "hidden system prompt" not in prompt
|
||||
assert "⏳ public scene note" in prompt
|
||||
|
||||
|
||||
def test_malformed_persona_and_transcript_are_bounded_before_prompting():
|
||||
stub = StubLLM([json.dumps({"reply": "รับทราบค่ะ", "decision": "none", "mood": 0})])
|
||||
|
||||
reply, meta = Simulator(stub).persona_reply(
|
||||
persona=_persona(channel={"internal": "secret"}, tolerance="not-a-number"),
|
||||
sales_kit={"productName": "สินค้า"},
|
||||
messages=[None, "malformed", {"role": "seller", "text": "ขอรายละเอียดเพิ่มครับ"}],
|
||||
internal={"score": {"hidden": "state"}},
|
||||
)
|
||||
|
||||
assert reply == "รับทราบค่ะ"
|
||||
assert meta == {"decision": "none", "mood": 0}
|
||||
prompt = "\n".join(message["content"] for message in stub.calls[0])
|
||||
assert "internal" not in prompt
|
||||
assert "not-a-number" not in prompt
|
||||
|
||||
|
||||
def test_partial_protocol_without_braces_never_leaks_into_bubble():
|
||||
# Provider appended contract fields as plain text (no braces) — the exact
|
||||
# raw leakage seen in production. Must NOT be rendered verbatim; it must
|
||||
|
||||
@@ -9,6 +9,7 @@ from types import SimpleNamespace
|
||||
import pytest
|
||||
|
||||
from app.api import analytics_routes, group_routes
|
||||
from app.api.chat_routes import serialize_session
|
||||
from app.api.helpers import ApiError
|
||||
from app.config import Config
|
||||
from app.services.file_parser import ParseError, parse_pdf
|
||||
@@ -32,17 +33,22 @@ def _setup_default_admin(user_store, login) -> str:
|
||||
return login("admin", "post-review-admin-password")["token"]
|
||||
|
||||
|
||||
def _raw_group(group_store, *, personas=None, sales_kit=None):
|
||||
def _raw_group(group_store, *, personas=None, sales_kit=None, report=None):
|
||||
group = group_store.create(
|
||||
org_id="org-default",
|
||||
creator_id="admin",
|
||||
title="Post-review group",
|
||||
)
|
||||
return group_store.update(
|
||||
return group_store.groups.update(
|
||||
group["id"],
|
||||
status="ready",
|
||||
input={"product": "CRM", "channel": "social", "language": "th"},
|
||||
sales_kit=sales_kit,
|
||||
sales_kit=(
|
||||
{"productName": "Test product"}
|
||||
if sales_kit is None
|
||||
else sales_kit
|
||||
),
|
||||
report=report or {"summary": "Test report"},
|
||||
personas=[] if personas is None else personas,
|
||||
)
|
||||
|
||||
@@ -68,6 +74,8 @@ def _cross_tenant_chat_session(client, user_store, login, *, status="active"):
|
||||
group = group_store.update(
|
||||
group["id"],
|
||||
status="ready",
|
||||
sales_kit={"productName": "Test product"},
|
||||
report={"summary": "Test report"},
|
||||
personas=[
|
||||
{
|
||||
"id": "hidden-persona",
|
||||
@@ -165,6 +173,8 @@ def test_chat_start_rechecks_resumed_session_context(client, user_store, login):
|
||||
group_store.update(
|
||||
group["id"],
|
||||
status="ready",
|
||||
sales_kit={"productName": "Test product"},
|
||||
report={"summary": "Test report"},
|
||||
personas=[{"id": "resume-persona", "name": "Resume Persona", "pains": []}],
|
||||
)
|
||||
session_store = client.application.extensions["session_store"]
|
||||
@@ -189,7 +199,7 @@ def test_chat_start_rechecks_resumed_session_context(client, user_store, login):
|
||||
headers=_headers(token),
|
||||
)
|
||||
|
||||
assert response.status_code == 404, response.get_json()
|
||||
assert response.status_code == 400, response.get_json()
|
||||
|
||||
|
||||
def test_weak_area_routes_ignore_sessions_with_unauthorized_group(
|
||||
@@ -211,6 +221,8 @@ def test_weak_area_routes_ignore_sessions_with_unauthorized_group(
|
||||
group["id"],
|
||||
status="ready",
|
||||
owner_user_id="another-owner",
|
||||
sales_kit={"productName": "Test product"},
|
||||
report={"summary": "Test report"},
|
||||
personas=[{"id": "stale-persona", "name": "Stale Persona", "pains": []}],
|
||||
)
|
||||
session_store = client.application.extensions["session_store"]
|
||||
@@ -263,7 +275,8 @@ def test_private_group_is_published_atomically(tmp_path: Path, monkeypatch):
|
||||
)
|
||||
|
||||
assert group["owner_user_id"] == "trainee-1"
|
||||
assert group["status"] == "ready"
|
||||
assert group["status"] == "draft"
|
||||
assert group["personas"] == []
|
||||
assert group["input"]["language"] == "th"
|
||||
|
||||
|
||||
@@ -290,7 +303,7 @@ def test_chat_rejects_falsey_private_owner_markers(
|
||||
creator_id="private-owner",
|
||||
title="Malformed private group",
|
||||
)
|
||||
group_store.update(
|
||||
group_store.groups.update(
|
||||
group["id"],
|
||||
status="ready",
|
||||
owner_user_id=owner_marker,
|
||||
@@ -393,8 +406,8 @@ def test_persona_update_skips_malformed_records_before_target(
|
||||
headers=_headers(token),
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert response.get_json()["persona"]["name"] == "After"
|
||||
assert response.status_code == 403
|
||||
assert response.get_json()["error"] == "group not ready"
|
||||
|
||||
|
||||
def test_report_and_persona_routes_fail_closed_on_malformed_collections(
|
||||
@@ -403,14 +416,15 @@ def test_report_and_persona_routes_fail_closed_on_malformed_collections(
|
||||
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)
|
||||
group_store.groups.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()
|
||||
assert report.status_code == 403
|
||||
assert report.get_json()["error"] == "group not ready"
|
||||
|
||||
user = user_store.create_user(
|
||||
org_id="org-default",
|
||||
@@ -424,6 +438,8 @@ def test_report_and_persona_routes_fail_closed_on_malformed_collections(
|
||||
group_store.update(
|
||||
group["id"],
|
||||
status="ready",
|
||||
sales_kit={"productName": "Test product"},
|
||||
report={"summary": "Test report"},
|
||||
personas=[{"id": "persona-1", "name": "Persona", "tier": "A"}],
|
||||
)
|
||||
sessions = client.application.extensions["session_store"].sessions
|
||||
@@ -504,20 +520,18 @@ def test_report_skips_nested_secret_values_and_nonfinite_numbers(
|
||||
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()
|
||||
assert response.status_code == 403, response.get_json()
|
||||
assert response.get_json() == {"error": "group not ready"}
|
||||
|
||||
|
||||
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"})
|
||||
group = _raw_group(
|
||||
group_store,
|
||||
personas=[{"id": "persona-1", "name": "Persona", "tier": "A"}],
|
||||
)
|
||||
group_store.groups.update(group["id"], title={"secret": "must-not-leak"})
|
||||
|
||||
response = client.get(
|
||||
f"/api/groups/{group['id']}/report?format=json",
|
||||
@@ -685,7 +699,13 @@ def test_super_admin_csv_export_excludes_non_trainee_owned_sessions(
|
||||
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"
|
||||
org_id="org-default",
|
||||
creator_id="admin",
|
||||
title="Global export group",
|
||||
status="ready",
|
||||
sales_kit={"productName": "Test product"},
|
||||
report={"summary": "Test report"},
|
||||
personas=[{"id": "export-trainee-session", "name": "Export Persona"}],
|
||||
)
|
||||
for key, user_id in (
|
||||
("export-super-admin-session", "admin"),
|
||||
@@ -734,7 +754,16 @@ def test_csv_export_has_a_row_cap(client, user_store, login, monkeypatch):
|
||||
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"
|
||||
org_id="org-default",
|
||||
creator_id="admin",
|
||||
title="Export group",
|
||||
status="ready",
|
||||
sales_kit={"productName": "Test product"},
|
||||
report={"summary": "Test report"},
|
||||
personas=[
|
||||
{"id": "export-row-1", "name": "Persona"},
|
||||
{"id": "export-row-2", "name": "Persona"},
|
||||
],
|
||||
)
|
||||
user = user_store.create_user(
|
||||
org_id="org-default",
|
||||
@@ -788,6 +817,7 @@ def test_csv_export_legacy_collection_does_not_materialize_where(
|
||||
"org_id": "org-default",
|
||||
"user_id": user["id"],
|
||||
"group_id": "shared-group",
|
||||
"persona_id": "legacy-export-1",
|
||||
"mode": "trainee",
|
||||
"status": "finished",
|
||||
"outcome": "won",
|
||||
@@ -797,6 +827,7 @@ def test_csv_export_legacy_collection_does_not_materialize_where(
|
||||
"org_id": "org-default",
|
||||
"user_id": user["id"],
|
||||
"group_id": "shared-group",
|
||||
"persona_id": "legacy-export-2",
|
||||
"mode": "trainee",
|
||||
"status": "finished",
|
||||
"outcome": "lost",
|
||||
@@ -813,12 +844,28 @@ def test_csv_export_legacy_collection_does_not_materialize_where(
|
||||
fake_stores = {
|
||||
"groups": SimpleNamespace(
|
||||
groups=SimpleNamespace(
|
||||
iter_all=lambda: iter([{"id": "shared-group", "org_id": "org-default"}])
|
||||
iter_all=lambda: iter([{
|
||||
"id": "shared-group",
|
||||
"org_id": "org-default",
|
||||
"visibility": "public",
|
||||
"status": "ready",
|
||||
"sales_kit": {"productName": "Legacy product"},
|
||||
"report": {"summary": "Legacy report"},
|
||||
"personas": [
|
||||
{"id": "legacy-export-1"},
|
||||
{"id": "legacy-export-2"},
|
||||
],
|
||||
}])
|
||||
)
|
||||
),
|
||||
"sessions": SimpleNamespace(sessions=LegacyCollection()),
|
||||
}
|
||||
monkeypatch.setattr(analytics_routes, "_stores", lambda: fake_stores)
|
||||
monkeypatch.setattr(
|
||||
client.application.extensions["user_store"],
|
||||
"list_users",
|
||||
lambda org_id=None: [{"id": user["id"], "username": user["username"], "role": "user"}],
|
||||
)
|
||||
monkeypatch.setattr(Config, "ANALYTICS_EXPORT_MAX_ROWS", 1)
|
||||
|
||||
with client.application.app_context(), pytest.raises(ApiError, match="too many"):
|
||||
@@ -957,3 +1004,109 @@ def test_upload_rejects_control_character_filename(client, user_store, login):
|
||||
)
|
||||
|
||||
assert response.status_code == 400, response.get_json()
|
||||
|
||||
|
||||
def test_customer_opener_is_redacted_from_public_session_transcript(
|
||||
client, user_store, login
|
||||
):
|
||||
_setup_default_admin(user_store, login)
|
||||
trainee = user_store.create_user(
|
||||
org_id="org-default",
|
||||
username="opener-redaction-user",
|
||||
password="opener-redaction-password",
|
||||
name="Opener Redaction User",
|
||||
role="user",
|
||||
must_setup=False,
|
||||
)
|
||||
token = login(trainee["username"], "opener-redaction-password")["token"]
|
||||
group_store = client.application.extensions["group_store"]
|
||||
group = group_store.create(org_id="org-default", creator_id="admin", title="Opener")
|
||||
group_store.update(
|
||||
group["id"],
|
||||
status="ready",
|
||||
sales_kit={"productName": "Test product"},
|
||||
report={"summary": "Test report"},
|
||||
personas=[
|
||||
{
|
||||
"id": "opener-persona",
|
||||
"name": "Opener Persona",
|
||||
"opener": "PRIVATE_PERSONA_OPENER_MUST_NOT_APPEAR",
|
||||
}
|
||||
],
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/api/chat/{group['id']}/personas/opener-persona/chat/start",
|
||||
json={"scenario": "social", "locale": "en"},
|
||||
headers=_headers(token),
|
||||
)
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
session = response.get_json()["session"]
|
||||
assert "PRIVATE_PERSONA_OPENER_MUST_NOT_APPEAR" not in str(session)
|
||||
assert any(message["role"] == "customer" for message in session["messages"])
|
||||
|
||||
|
||||
def test_session_serializer_replaces_legacy_customer_opener():
|
||||
view = serialize_session(
|
||||
{
|
||||
"id": "legacy-opener-session",
|
||||
"group_id": "group-1",
|
||||
"persona_id": "persona-1",
|
||||
"persona_name": "Customer",
|
||||
"status": "active",
|
||||
"persona_meta": {"initiation_mode": "customer", "locale": "en"},
|
||||
"messages": [
|
||||
{"role": "customer", "text": "PRIVATE_LEGACY_OPENER"},
|
||||
{"role": "seller", "text": "Hello"},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert "PRIVATE_LEGACY_OPENER" not in str(view)
|
||||
assert view["messages"][0]["role"] == "customer"
|
||||
|
||||
|
||||
def test_board_ignores_non_ready_groups_and_unauthorized_sessions(
|
||||
client, user_store, login
|
||||
):
|
||||
_setup_default_admin(user_store, login)
|
||||
trainee = user_store.create_user(
|
||||
org_id="org-default",
|
||||
username="board-context-user",
|
||||
password="board-context-password",
|
||||
name="Board Context User",
|
||||
role="user",
|
||||
must_setup=False,
|
||||
)
|
||||
token = login(trainee["username"], "board-context-password")["token"]
|
||||
group_store = client.application.extensions["group_store"]
|
||||
group = group_store.create(
|
||||
org_id="org-default",
|
||||
creator_id=trainee["id"],
|
||||
title="Failed private product",
|
||||
)
|
||||
group_store.update(
|
||||
group["id"],
|
||||
status="failed",
|
||||
owner_user_id=trainee["id"],
|
||||
personas=[{"id": "failed-persona", "name": "Failed Persona"}],
|
||||
)
|
||||
client.application.extensions["session_store"].sessions.create(
|
||||
{
|
||||
"id": "stale-failed-board-session",
|
||||
"org_id": "org-default",
|
||||
"user_id": trainee["id"],
|
||||
"group_id": group["id"],
|
||||
"persona_id": "failed-persona",
|
||||
"mode": "trainee",
|
||||
"status": "finished",
|
||||
"outcome": "won",
|
||||
},
|
||||
key="stale-failed-board-session",
|
||||
)
|
||||
|
||||
response = client.get("/api/me/board", headers=_headers(token))
|
||||
|
||||
assert response.status_code == 200, response.get_json()
|
||||
assert response.get_json()["board"] == []
|
||||
|
||||
@@ -5,6 +5,8 @@ from concurrent.futures import ThreadPoolExecutor
|
||||
from pathlib import Path
|
||||
from threading import Barrier
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.groups import GroupStore
|
||||
|
||||
|
||||
@@ -26,6 +28,96 @@ def test_private_group_lookup_and_create_is_atomic(tmp_path: Path):
|
||||
assert len({group["id"] for group in groups}) == 1
|
||||
assert len(store.list_for_org("org-1")) == 1
|
||||
assert groups[0]["owner_user_id"] == "user-1"
|
||||
assert groups[0]["status"] == "draft"
|
||||
assert groups[0]["personas"] == []
|
||||
|
||||
|
||||
def test_private_group_lookup_revalidates_candidate_after_snapshot(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
store = GroupStore(tmp_path)
|
||||
existing = store.get_or_create_private_group(
|
||||
org_id="org-1", owner_user_id="user-1", owner_name="Trainee"
|
||||
)
|
||||
original_list = store.list_for_org
|
||||
transferred = False
|
||||
|
||||
def list_then_transfer(*, org_id):
|
||||
nonlocal transferred
|
||||
rows = original_list(org_id=org_id)
|
||||
if rows and not transferred:
|
||||
transferred = True
|
||||
store.groups.update(rows[0]["id"], owner_user_id="user-2")
|
||||
return rows
|
||||
|
||||
monkeypatch.setattr(store, "list_for_org", list_then_transfer)
|
||||
|
||||
selected = store.get_or_create_private_group(
|
||||
org_id="org-1", owner_user_id="user-1", owner_name="Trainee"
|
||||
)
|
||||
|
||||
assert selected["id"] != existing["id"]
|
||||
assert selected["owner_user_id"] == "user-1"
|
||||
|
||||
|
||||
@pytest.mark.parametrize("operation", ["get_or_create", "append"])
|
||||
def test_private_group_lookup_fails_closed_on_malformed_candidate_id(
|
||||
tmp_path: Path, monkeypatch, operation: str
|
||||
):
|
||||
store = GroupStore(tmp_path)
|
||||
calls = 0
|
||||
|
||||
def malformed_rows(*, org_id):
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
if calls > 1:
|
||||
raise AssertionError("malformed private-group candidates must not retry forever")
|
||||
return [{"id": 0, "org_id": org_id, "owner_user_id": "user-1"}]
|
||||
|
||||
monkeypatch.setattr(store, "list_for_org", malformed_rows)
|
||||
|
||||
with pytest.raises(ValueError, match="private group state"):
|
||||
if operation == "get_or_create":
|
||||
store.get_or_create_private_group(
|
||||
org_id="org-1", owner_user_id="user-1", owner_name="Trainee"
|
||||
)
|
||||
else:
|
||||
store.append_private_persona(
|
||||
org_id="org-1",
|
||||
owner_user_id="user-1",
|
||||
persona={"id": "variant", "name": "Variant"},
|
||||
)
|
||||
|
||||
assert calls == 1
|
||||
|
||||
|
||||
def test_private_persona_append_revalidates_target_state(
|
||||
tmp_path: Path, monkeypatch
|
||||
):
|
||||
store = GroupStore(tmp_path)
|
||||
group = store.append_private_persona(
|
||||
org_id="org-1",
|
||||
owner_user_id="user-1",
|
||||
persona={"id": "initial", "name": "Initial"},
|
||||
)
|
||||
original_record_lock = store.record_lock
|
||||
transitioned = False
|
||||
|
||||
def lock_after_transition(gid):
|
||||
nonlocal transitioned
|
||||
if gid == group["id"] and not transitioned:
|
||||
transitioned = True
|
||||
store.groups.update(gid, status="failed")
|
||||
return original_record_lock(gid)
|
||||
|
||||
monkeypatch.setattr(store, "record_lock", lock_after_transition)
|
||||
|
||||
with pytest.raises(ValueError, match="not ready"):
|
||||
store.append_private_persona(
|
||||
org_id="org-1",
|
||||
owner_user_id="user-1",
|
||||
persona={"id": "variant", "name": "Variant"},
|
||||
)
|
||||
|
||||
|
||||
def test_private_groups_are_scoped_by_org_and_owner(tmp_path: Path):
|
||||
@@ -43,6 +135,83 @@ def test_private_groups_are_scoped_by_org_and_owner(tmp_path: Path):
|
||||
assert len(store.list_for_org("org-2")) == 1
|
||||
|
||||
|
||||
def test_private_persona_append_publishes_ready_group_with_persona(tmp_path: Path):
|
||||
store = GroupStore(tmp_path)
|
||||
|
||||
group = store.append_private_persona(
|
||||
org_id="org-1",
|
||||
owner_user_id="user-1",
|
||||
owner_name="Trainee",
|
||||
persona={
|
||||
"id": "variant-1",
|
||||
"name": "Variant",
|
||||
"channel": "facebook",
|
||||
"initiation_mode": "customer",
|
||||
},
|
||||
)
|
||||
|
||||
assert group["status"] == "ready"
|
||||
assert [persona["id"] for persona in group["personas"]] == ["variant-1"]
|
||||
persisted = store.get(group["id"])
|
||||
assert persisted["status"] == "ready"
|
||||
assert [persona["id"] for persona in persisted["personas"]] == ["variant-1"]
|
||||
|
||||
|
||||
def test_private_persona_append_can_publish_the_initial_draft(tmp_path: Path):
|
||||
store = GroupStore(tmp_path)
|
||||
draft = store.get_or_create_private_group(
|
||||
org_id="org-1", owner_user_id="user-1", owner_name="Trainee"
|
||||
)
|
||||
|
||||
published = store.append_private_persona(
|
||||
org_id="org-1",
|
||||
owner_user_id="user-1",
|
||||
persona={"id": "first", "name": "First variant"},
|
||||
)
|
||||
|
||||
assert published["id"] == draft["id"]
|
||||
assert published["status"] == "ready"
|
||||
assert [persona["id"] for persona in published["personas"]] == ["first"]
|
||||
|
||||
|
||||
def test_private_group_cannot_publish_ready_without_a_persona(tmp_path: Path):
|
||||
store = GroupStore(tmp_path)
|
||||
draft = store.get_or_create_private_group(
|
||||
org_id="org-1", owner_user_id="user-1", owner_name="Trainee"
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="persona"):
|
||||
store.update(draft["id"], status="ready")
|
||||
|
||||
|
||||
def test_private_group_rejects_truthy_non_list_personas_when_ready(tmp_path: Path):
|
||||
store = GroupStore(tmp_path)
|
||||
|
||||
with pytest.raises(ValueError, match="persona"):
|
||||
store.create(
|
||||
org_id="org-1",
|
||||
creator_id="user-1",
|
||||
owner_user_id="user-1",
|
||||
title="Malformed ready group",
|
||||
status="ready",
|
||||
personas={"id": "not-a-list"},
|
||||
)
|
||||
|
||||
|
||||
def test_private_group_rejects_malformed_persona_traits_when_ready(tmp_path: Path):
|
||||
store = GroupStore(tmp_path)
|
||||
|
||||
with pytest.raises(ValueError, match="persona traits"):
|
||||
store.create(
|
||||
org_id="org-1",
|
||||
creator_id="user-1",
|
||||
owner_user_id="user-1",
|
||||
title="Malformed ready persona",
|
||||
status="ready",
|
||||
personas=[{"id": "p-1", "name": "Bad", "channel": "internal"}],
|
||||
)
|
||||
|
||||
|
||||
def test_locked_persona_appends_preserve_all_concurrent_variants(tmp_path: Path):
|
||||
store = GroupStore(tmp_path)
|
||||
group = store.get_or_create_private_group(org_id="org-1", owner_user_id="user-1")
|
||||
@@ -66,7 +235,13 @@ def test_locked_persona_appends_preserve_all_concurrent_variants(tmp_path: Path)
|
||||
|
||||
def test_record_lock_is_reentrant_for_nested_service_writes(tmp_path: Path):
|
||||
store = GroupStore(tmp_path)
|
||||
group = store.create(org_id="org-1", creator_id="user-1", title="Nested")
|
||||
group = store.create(
|
||||
org_id="org-1",
|
||||
creator_id="user-1",
|
||||
title="Nested",
|
||||
sales_kit={"productName": "Test product"},
|
||||
report={"summary": "Test report"},
|
||||
)
|
||||
with store.record_lock(group["id"]):
|
||||
with store.record_lock(group["id"]):
|
||||
store.set_personas(group["id"], [{"id": "persona-1", "name": "Nested"}])
|
||||
|
||||
@@ -133,3 +133,33 @@ def test_other_trainee_cannot_access_private_variant(client, user_store, login):
|
||||
)
|
||||
|
||||
assert response.status_code == 403
|
||||
|
||||
|
||||
def test_reanalysis_preserves_an_accepted_variant(client, user_store, login):
|
||||
admin_token = _setup(user_store, login)
|
||||
client.application.extensions["llm"] = MockLLM()
|
||||
created = client.post(
|
||||
"/api/groups",
|
||||
json={"product": "CRM", "segment": "SME"},
|
||||
headers=_headers(admin_token),
|
||||
)
|
||||
gid = created.get_json()["group"]["id"]
|
||||
analyzed = client.post(f"/api/groups/{gid}/analyze", headers=_headers(admin_token))
|
||||
source_id = analyzed.get_json()["personas"][0]["id"]
|
||||
|
||||
client.application.extensions["llm"] = VariantLLM()
|
||||
variant_response = client.post(
|
||||
f"/api/groups/{gid}/personas/{source_id}/variant",
|
||||
headers=_headers(admin_token),
|
||||
)
|
||||
assert variant_response.status_code == 201, variant_response.get_json()
|
||||
variant_id = variant_response.get_json()["persona"]["id"]
|
||||
|
||||
client.application.extensions["llm"] = MockLLM()
|
||||
reanalyzed = client.post(f"/api/groups/{gid}/analyze", headers=_headers(admin_token))
|
||||
|
||||
assert reanalyzed.status_code == 200, reanalyzed.get_json()
|
||||
assert variant_id in {
|
||||
persona["id"]
|
||||
for persona in client.application.extensions["group_store"].get(gid)["personas"]
|
||||
}
|
||||
|
||||
428
backend/tests/test_review_wave1_regressions.py
Normal file
428
backend/tests/test_review_wave1_regressions.py
Normal file
@@ -0,0 +1,428 @@
|
||||
"""Regression tests for the first current-tree independent review wave."""
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from app.services.groups import GroupStore
|
||||
from scripts.mock_llm import MockLLM
|
||||
from test_private_variant import VariantLLM
|
||||
|
||||
|
||||
def _headers(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _setup_super_admin(user_store, login) -> str:
|
||||
user_store.complete_setup(
|
||||
"admin",
|
||||
"wave1-admin@example.com",
|
||||
"wave1-admin-password",
|
||||
accepted_terms=True,
|
||||
accepted_terms_at="2026-08-24T00:00:00Z",
|
||||
)
|
||||
return login("admin", "wave1-admin-password")["token"]
|
||||
|
||||
|
||||
def _analyzed_group(client, token: str) -> tuple[str, str]:
|
||||
client.application.extensions["llm"] = MockLLM()
|
||||
created = client.post(
|
||||
"/api/groups",
|
||||
json={"product": "CRM", "segment": "SME"},
|
||||
headers=_headers(token),
|
||||
)
|
||||
assert created.status_code == 201, created.get_json()
|
||||
gid = created.get_json()["group"]["id"]
|
||||
analyzed = client.post(f"/api/groups/{gid}/analyze", headers=_headers(token))
|
||||
assert analyzed.status_code == 200, analyzed.get_json()
|
||||
return gid, analyzed.get_json()["personas"][0]["id"]
|
||||
|
||||
|
||||
def test_super_admin_cannot_list_or_read_owner_private_group(client, user_store, login):
|
||||
token = _setup_super_admin(user_store, login)
|
||||
trainee = user_store.create_user(
|
||||
org_id="org-default",
|
||||
username="wave1-owner",
|
||||
password="wave1-owner-password",
|
||||
name="Wave 1 Owner",
|
||||
role="user",
|
||||
must_setup=False,
|
||||
)
|
||||
gid, pid = _analyzed_group(client, token)
|
||||
groups = client.application.extensions["group_store"]
|
||||
groups.update(gid, owner_user_id=trainee["id"])
|
||||
|
||||
listing = client.get("/api/groups", headers=_headers(token))
|
||||
assert listing.status_code == 200
|
||||
assert gid not in {group["id"] for group in listing.get_json()["groups"]}
|
||||
|
||||
responses = [
|
||||
client.get(f"/api/groups/{gid}", headers=_headers(token)),
|
||||
client.get(f"/api/groups/{gid}/report", headers=_headers(token)),
|
||||
client.get(f"/api/groups/{gid}/personas", headers=_headers(token)),
|
||||
client.get(f"/api/groups/{gid}/personas/{pid}", headers=_headers(token)),
|
||||
client.post(
|
||||
f"/api/chat/{gid}/personas/{pid}/chat/start",
|
||||
json={"scenario": "social"},
|
||||
headers=_headers(token),
|
||||
),
|
||||
]
|
||||
statuses = [response.status_code for response in responses]
|
||||
assert all(status in {403, 404} for status in statuses), statuses
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path_suffix", ["", "/report", "/personas"])
|
||||
def test_privileged_reads_reject_malformed_ready_artifacts(
|
||||
client, user_store, login, path_suffix: str
|
||||
):
|
||||
token = _setup_super_admin(user_store, login)
|
||||
groups = client.application.extensions["group_store"]
|
||||
group = groups.create(org_id="org-default", creator_id="admin", title="Corrupt ready")
|
||||
groups.groups.update(
|
||||
group["id"],
|
||||
status="ready",
|
||||
sales_kit=None,
|
||||
report=None,
|
||||
personas=[],
|
||||
)
|
||||
|
||||
response = client.get(
|
||||
f"/api/groups/{group['id']}{path_suffix}", headers=_headers(token)
|
||||
)
|
||||
|
||||
assert response.status_code in {403, 404}
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", ["", "READY", "published", None, 0])
|
||||
def test_group_store_rejects_invalid_lifecycle_status_on_create(tmp_path, status):
|
||||
store = GroupStore(tmp_path)
|
||||
|
||||
with pytest.raises(ValueError, match="status"):
|
||||
store.create(
|
||||
org_id="org-1",
|
||||
creator_id="admin-1",
|
||||
title="Invalid lifecycle",
|
||||
status=status,
|
||||
)
|
||||
|
||||
|
||||
def test_group_store_does_not_normalize_corrupt_persisted_status(tmp_path):
|
||||
store = GroupStore(tmp_path)
|
||||
group = store.create(org_id="org-1", creator_id="admin-1", title="Corrupt lifecycle")
|
||||
store.groups.update(group["id"], status="published")
|
||||
|
||||
with pytest.raises(ValueError, match="status"):
|
||||
store.update(group["id"], status="analyzing")
|
||||
|
||||
|
||||
class ReanalyzingVariantLLM(VariantLLM):
|
||||
def __init__(self, group_store, gid: str):
|
||||
self.group_store = group_store
|
||||
self.gid = gid
|
||||
|
||||
def complete_json(self, system_prompt, user_prompt, **kwargs):
|
||||
current = self.group_store.get(self.gid)
|
||||
replacement = dict(current["personas"][1])
|
||||
self.group_store.groups.update(self.gid, personas=[replacement])
|
||||
return super().complete_json(system_prompt, user_prompt, **kwargs)
|
||||
|
||||
|
||||
def test_admin_variant_rejects_source_replaced_during_generation(client, user_store, login):
|
||||
token = _setup_super_admin(user_store, login)
|
||||
gid, source_id = _analyzed_group(client, token)
|
||||
groups = client.application.extensions["group_store"]
|
||||
client.application.extensions["llm"] = ReanalyzingVariantLLM(groups, gid)
|
||||
|
||||
response = client.post(
|
||||
f"/api/groups/{gid}/personas/{source_id}/variant",
|
||||
headers=_headers(token),
|
||||
)
|
||||
|
||||
assert response.status_code in {403, 404, 409}
|
||||
persisted = groups.get(gid)
|
||||
assert all(
|
||||
persona.get("source_persona_id") != source_id
|
||||
for persona in persisted.get("personas", [])
|
||||
if isinstance(persona, dict)
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("role", ["admin", "super_admin"])
|
||||
def test_privileged_indexes_exclude_malformed_ready_groups(tmp_path, role):
|
||||
store = GroupStore(tmp_path)
|
||||
group = store.create(org_id="org-1", creator_id="admin-1", title="Corrupt index")
|
||||
store.groups.update(
|
||||
group["id"], status="ready", sales_kit=None, report=None, personas=[]
|
||||
)
|
||||
|
||||
visible = store.list_visible_to(role=role, org_id="org-1", user_id="admin-1")
|
||||
|
||||
assert group["id"] not in {record["id"] for record in visible}
|
||||
|
||||
|
||||
def test_publish_analysis_rejects_corrupt_or_non_analyzing_source_state(tmp_path):
|
||||
store = GroupStore(tmp_path)
|
||||
group = store.create(org_id="org-1", creator_id="admin-1", title="Corrupt publish")
|
||||
store.groups.update(group["id"], status="corrupt")
|
||||
|
||||
with pytest.raises(ValueError, match="status"):
|
||||
store.publish_analysis(
|
||||
group["id"],
|
||||
sales_kit={"productName": "CRM"},
|
||||
report={"summary": "ready"},
|
||||
personas=[{"id": "persona-1", "name": "Buyer"}],
|
||||
)
|
||||
|
||||
|
||||
def test_ready_contract_rejects_duplicate_persona_ids(tmp_path):
|
||||
store = GroupStore(tmp_path)
|
||||
group = store.create(org_id="org-1", creator_id="admin-1", title="Duplicates")
|
||||
store.update(group["id"], status="analyzing")
|
||||
|
||||
with pytest.raises(ValueError, match="persona id"):
|
||||
store.publish_analysis(
|
||||
group["id"],
|
||||
sales_kit={"productName": "CRM"},
|
||||
report={"summary": "ready"},
|
||||
personas=[
|
||||
{"id": "persona-1", "name": "Buyer A"},
|
||||
{"id": "persona-1", "name": "Buyer B"},
|
||||
],
|
||||
)
|
||||
|
||||
|
||||
class MutatingSameIdVariantLLM(VariantLLM):
|
||||
def __init__(self, group_store, gid: str, source_id: str):
|
||||
self.group_store = group_store
|
||||
self.gid = gid
|
||||
self.source_id = source_id
|
||||
|
||||
def complete_json(self, system_prompt, user_prompt, **kwargs):
|
||||
current = self.group_store.get(self.gid)
|
||||
personas = [dict(persona) for persona in current["personas"]]
|
||||
source = next(persona for persona in personas if persona["id"] == self.source_id)
|
||||
source["name"] = "Changed while generating"
|
||||
source["pains"] = ["new challenge"]
|
||||
self.group_store.groups.update(self.gid, personas=personas)
|
||||
return super().complete_json(system_prompt, user_prompt, **kwargs)
|
||||
|
||||
|
||||
def test_admin_variant_rejects_same_id_source_mutation(client, user_store, login):
|
||||
token = _setup_super_admin(user_store, login)
|
||||
gid, source_id = _analyzed_group(client, token)
|
||||
groups = client.application.extensions["group_store"]
|
||||
client.application.extensions["llm"] = MutatingSameIdVariantLLM(
|
||||
groups, gid, source_id
|
||||
)
|
||||
|
||||
response = client.post(
|
||||
f"/api/groups/{gid}/personas/{source_id}/variant",
|
||||
headers=_headers(token),
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert not any(
|
||||
persona.get("source_persona_id") == source_id
|
||||
for persona in groups.get(gid)["personas"]
|
||||
)
|
||||
|
||||
|
||||
class DeletingSourceVariantLLM(VariantLLM):
|
||||
def __init__(self, group_store, gid: str):
|
||||
self.group_store = group_store
|
||||
self.gid = gid
|
||||
|
||||
def complete_json(self, system_prompt, user_prompt, **kwargs):
|
||||
self.group_store.delete(self.gid)
|
||||
return super().complete_json(system_prompt, user_prompt, **kwargs)
|
||||
|
||||
|
||||
def test_trainee_variant_revalidates_source_before_private_publication(
|
||||
client, user_store, login
|
||||
):
|
||||
admin_token = _setup_super_admin(user_store, login)
|
||||
gid, source_id = _analyzed_group(client, admin_token)
|
||||
trainee = user_store.create_user(
|
||||
org_id="org-default",
|
||||
username="wave1-variant-trainee",
|
||||
password="wave1-variant-password",
|
||||
name="Wave 1 Variant Trainee",
|
||||
role="user",
|
||||
must_setup=False,
|
||||
)
|
||||
trainee_token = login(trainee["username"], "wave1-variant-password")["token"]
|
||||
groups = client.application.extensions["group_store"]
|
||||
client.application.extensions["llm"] = DeletingSourceVariantLLM(groups, gid)
|
||||
|
||||
response = client.post(
|
||||
f"/api/groups/{gid}/personas/{source_id}/variant",
|
||||
headers=_headers(trainee_token),
|
||||
)
|
||||
|
||||
assert response.status_code in {404, 409}
|
||||
assert not any(
|
||||
group.get("owner_user_id") == trainee["id"]
|
||||
for group in groups.list_for_org("org-default")
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("promoted_role", ["admin", "super_admin"])
|
||||
def test_promoted_privileged_owner_keeps_private_group_access(
|
||||
client, user_store, login, promoted_role: str
|
||||
):
|
||||
owner = user_store.create_user(
|
||||
org_id="org-default",
|
||||
username=f"wave1-promoted-{promoted_role}",
|
||||
password="wave1-promoted-password",
|
||||
name="Promoted Owner",
|
||||
role="user",
|
||||
must_setup=False,
|
||||
)
|
||||
owner_token = login(owner["username"], "wave1-promoted-password")["token"]
|
||||
gid, pid = _analyzed_group(client, owner_token)
|
||||
user_store.set_role(owner["username"], promoted_role)
|
||||
promoted_token = login(owner["username"], "wave1-promoted-password")["token"]
|
||||
|
||||
listing = client.get("/api/groups", headers=_headers(promoted_token))
|
||||
detail = client.get(f"/api/groups/{gid}", headers=_headers(promoted_token))
|
||||
analysis = client.post(f"/api/groups/{gid}/analyze", headers=_headers(promoted_token))
|
||||
chat = client.post(
|
||||
f"/api/chat/{gid}/personas/{pid}/chat/start",
|
||||
json={"scenario": "social"},
|
||||
headers=_headers(promoted_token),
|
||||
)
|
||||
|
||||
assert listing.status_code == 200
|
||||
listed = {group["id"]: group for group in listing.get_json()["groups"]}
|
||||
assert gid in listed
|
||||
assert listed[gid]["is_owned"] is True
|
||||
assert "owner_user_id" not in listed[gid]
|
||||
assert detail.status_code == 200
|
||||
assert detail.get_json()["group"]["is_owned"] is True
|
||||
assert "owner_user_id" not in detail.get_json()["group"]
|
||||
assert analysis.status_code == 200
|
||||
assert analysis.get_json()["group"]["is_owned"] is True
|
||||
assert "owner_user_id" not in analysis.get_json()["group"]
|
||||
assert chat.status_code == 200
|
||||
|
||||
|
||||
@pytest.mark.parametrize("stale_org_id", ["org-ghost", ""])
|
||||
def test_promoted_super_admin_owner_cannot_cross_private_group_tenant(
|
||||
client, user_store, login, stale_org_id: str
|
||||
):
|
||||
owner = user_store.create_user(
|
||||
org_id="org-default",
|
||||
username=f"wave1-stale-owner-{stale_org_id or 'invalid'}",
|
||||
password="wave1-stale-owner-password",
|
||||
name="Stale Tenant Owner",
|
||||
role="user",
|
||||
must_setup=False,
|
||||
)
|
||||
owner_token = login(owner["username"], "wave1-stale-owner-password")["token"]
|
||||
gid, pid = _analyzed_group(client, owner_token)
|
||||
groups = client.application.extensions["group_store"]
|
||||
groups.update(gid, org_id=stale_org_id)
|
||||
user_store.set_role(owner["username"], "super_admin")
|
||||
promoted_token = login(owner["username"], "wave1-stale-owner-password")["token"]
|
||||
|
||||
listing = client.get("/api/groups", headers=_headers(promoted_token))
|
||||
responses = (
|
||||
client.get(f"/api/groups/{gid}", headers=_headers(promoted_token)),
|
||||
client.post(f"/api/groups/{gid}/analyze", headers=_headers(promoted_token)),
|
||||
client.get(f"/api/groups/{gid}/personas", headers=_headers(promoted_token)),
|
||||
client.get(f"/api/groups/{gid}/personas/{pid}", headers=_headers(promoted_token)),
|
||||
client.post(
|
||||
f"/api/chat/{gid}/personas/{pid}/chat/start",
|
||||
json={"scenario": "social"},
|
||||
headers=_headers(promoted_token),
|
||||
),
|
||||
)
|
||||
|
||||
assert listing.status_code == 200
|
||||
assert gid not in {group["id"] for group in listing.get_json()["groups"]}
|
||||
assert all(response.status_code in {403, 404} for response in responses)
|
||||
|
||||
|
||||
class HidingSourceVariantLLM(VariantLLM):
|
||||
def __init__(self, group_store, gid: str):
|
||||
self.group_store = group_store
|
||||
self.gid = gid
|
||||
|
||||
def complete_json(self, system_prompt, user_prompt, **kwargs):
|
||||
self.group_store.update(self.gid, visibility="hidden")
|
||||
return super().complete_json(system_prompt, user_prompt, **kwargs)
|
||||
|
||||
|
||||
def test_admin_variant_rejects_source_hidden_during_generation(client, user_store, login):
|
||||
token = _setup_super_admin(user_store, login)
|
||||
gid, source_id = _analyzed_group(client, token)
|
||||
groups = client.application.extensions["group_store"]
|
||||
groups.update(gid, visibility="public")
|
||||
client.application.extensions["llm"] = HidingSourceVariantLLM(groups, gid)
|
||||
|
||||
response = client.post(
|
||||
f"/api/groups/{gid}/personas/{source_id}/variant",
|
||||
headers=_headers(token),
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert not any(
|
||||
persona.get("source_persona_id") == source_id
|
||||
for persona in groups.get(gid)["personas"]
|
||||
)
|
||||
|
||||
|
||||
class IdenticalReanalysisVariantLLM(VariantLLM):
|
||||
def __init__(self, group_store, gid: str):
|
||||
self.group_store = group_store
|
||||
self.gid = gid
|
||||
|
||||
def complete_json(self, system_prompt, user_prompt, **kwargs):
|
||||
current = self.group_store.get(self.gid)
|
||||
self.group_store.update(self.gid, status="analyzing")
|
||||
self.group_store.publish_analysis(
|
||||
self.gid,
|
||||
sales_kit=current["sales_kit"],
|
||||
report=current["report"],
|
||||
personas=current["personas"],
|
||||
)
|
||||
return super().complete_json(system_prompt, user_prompt, **kwargs)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("actor_role", ["admin", "user"])
|
||||
def test_variant_rejects_identical_reanalysis_during_generation(
|
||||
client, user_store, login, actor_role: str
|
||||
):
|
||||
admin_token = _setup_super_admin(user_store, login)
|
||||
gid, source_id = _analyzed_group(client, admin_token)
|
||||
token = admin_token
|
||||
actor_id = None
|
||||
if actor_role == "user":
|
||||
actor = user_store.create_user(
|
||||
org_id="org-default",
|
||||
username="wave1-reanalysis-trainee",
|
||||
password="wave1-reanalysis-password",
|
||||
name="Reanalysis Trainee",
|
||||
role="user",
|
||||
must_setup=False,
|
||||
)
|
||||
actor_id = actor["id"]
|
||||
token = login(actor["username"], "wave1-reanalysis-password")["token"]
|
||||
groups = client.application.extensions["group_store"]
|
||||
groups.update(gid, visibility="public")
|
||||
client.application.extensions["llm"] = IdenticalReanalysisVariantLLM(groups, gid)
|
||||
|
||||
response = client.post(
|
||||
f"/api/groups/{gid}/personas/{source_id}/variant",
|
||||
headers=_headers(token),
|
||||
)
|
||||
|
||||
assert response.status_code == 409
|
||||
assert not any(
|
||||
persona.get("source_persona_id") == source_id
|
||||
for persona in groups.get(gid)["personas"]
|
||||
)
|
||||
if actor_id is not None:
|
||||
assert not any(
|
||||
group.get("owner_user_id") == actor_id
|
||||
for group in groups.list_for_org("org-default")
|
||||
)
|
||||
1156
backend/tests/test_security_review_blockers.py
Normal file
1156
backend/tests/test_security_review_blockers.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -145,6 +145,11 @@ def test_judge_debrief_uses_closed_allowlist():
|
||||
},
|
||||
"won",
|
||||
{"pains": [{"description": "customer pain"}], "internal_secret": "must-not-leak"},
|
||||
{
|
||||
"why": "good fit",
|
||||
"failurePoints": ["missed discovery", {"provider_path": "/secret"}],
|
||||
"coaching": ["ask one more question"],
|
||||
},
|
||||
)
|
||||
|
||||
assert set(debrief) == {
|
||||
@@ -174,7 +179,7 @@ def test_group_serializer_fails_closed_on_malformed_internal_shapes():
|
||||
|
||||
assert view["error"] == "analysis_failed"
|
||||
assert view["input"] == {}
|
||||
assert len(view["personas"]) == 1
|
||||
assert view["personas"] == []
|
||||
assert "/private/provider/path" not in str(view)
|
||||
|
||||
|
||||
@@ -202,10 +207,38 @@ def test_session_serializer_omits_hidden_internal_state():
|
||||
assert "user_id" not in view
|
||||
assert "internal" not in view
|
||||
assert view["persona_meta"] == {"scenario": "social", "locale": "th"}
|
||||
assert view["messages"] == [{"role": "customer", "text": "hello"}]
|
||||
assert view["messages"] == [{
|
||||
"role": "customer",
|
||||
"text": "ลูกค้าเริ่มต้นบทสนทนาแล้ว ลองทักและค้นหาความต้องการดูครับ",
|
||||
}]
|
||||
assert "/private" not in str(view)
|
||||
|
||||
|
||||
def test_session_serializer_keeps_only_public_scene_notes():
|
||||
view = serialize_session(
|
||||
{
|
||||
"id": "session-2",
|
||||
"group_id": "group-1",
|
||||
"persona_id": "persona-1",
|
||||
"persona_name": "Customer",
|
||||
"status": "active",
|
||||
"messages": [
|
||||
{"role": "seller", "text": "hello"},
|
||||
{"role": "assistant", "text": "hidden judge output"},
|
||||
{"role": "system", "text": "hidden system prompt"},
|
||||
{"role": "system", "text": "⏳ ผ่านไป 2-3 สัปดาห์ ... ลูกค้าที่เคยสอบถามไปเงียบไประยะหนึ่ง ตอนนี้กลับมาติดต่ออีกครั้ง (พร้อมตัดสินใจมากขึ้น)"},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
assert view["messages"] == [
|
||||
{"role": "seller", "text": "hello"},
|
||||
{"role": "system", "text": "⏳ ผ่านไป 2-3 สัปดาห์ ... ลูกค้าที่เคยสอบถามไปเงียบไประยะหนึ่ง ตอนนี้กลับมาติดต่ออีกครั้ง (พร้อมตัดสินใจมากขึ้น)"},
|
||||
]
|
||||
assert "hidden judge output" not in str(view)
|
||||
assert "hidden system prompt" not in str(view)
|
||||
|
||||
|
||||
def test_super_admin_manages_admin_and_super_admin_roles(
|
||||
client, user_store, login
|
||||
):
|
||||
@@ -540,9 +573,12 @@ def test_trainee_group_view_drops_unknown_top_level_group_fields(
|
||||
group = group_store.create(
|
||||
org_id="org-default", creator_id="admin", title="Safe group"
|
||||
)
|
||||
group_store.update(
|
||||
group_store.groups.update(
|
||||
group["id"],
|
||||
status="ready",
|
||||
sales_kit={"productName": "Safe product"},
|
||||
report={"summary": "Safe report"},
|
||||
personas=[{"id": "safe-persona", "name": "Safe Persona"}],
|
||||
internal_secret="provider-key=/private/path/secret-token",
|
||||
)
|
||||
user = user_store.create_user(
|
||||
@@ -734,7 +770,7 @@ def test_group_persistence_failure_cleans_saved_upload(
|
||||
def explode(*args, **kwargs):
|
||||
raise OSError("storage=/private/path/persistence-secret")
|
||||
|
||||
monkeypatch.setattr(GroupStore, "update", explode)
|
||||
monkeypatch.setattr(GroupStore, "create", explode)
|
||||
response = client.post(
|
||||
"/api/groups",
|
||||
data={
|
||||
|
||||
316
backend/tests/test_wave2_review_regressions.py
Normal file
316
backend/tests/test_wave2_review_regressions.py
Normal file
@@ -0,0 +1,316 @@
|
||||
"""Regressions for confirmed Wave-2 Chat/session and auth review findings."""
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
|
||||
import jwt
|
||||
import pytest
|
||||
|
||||
from app.api.chat_routes import serialize_session
|
||||
from app.auth.users import AuthError
|
||||
from app.config import Config
|
||||
from app.services import oauth as oauth_svc
|
||||
from app.services.groups import is_ready_group
|
||||
from app.services.sessions import SessionStore
|
||||
|
||||
|
||||
def _headers(token: str) -> dict[str, str]:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
def _enable_google(monkeypatch, *, org_id: str = "org-default") -> None:
|
||||
monkeypatch.setattr(Config, "OAUTH_DEFAULT_ORG", org_id)
|
||||
monkeypatch.setattr(Config, "OAUTH_GOOGLE_CLIENT_ID", "google-client")
|
||||
monkeypatch.setattr(Config, "OAUTH_GOOGLE_CLIENT_SECRET", "google-secret")
|
||||
|
||||
|
||||
def _oauth(client):
|
||||
return client.post("/api/auth/oauth", json={"provider": "google", "token": "verified-token"})
|
||||
|
||||
|
||||
def test_serializer_redacts_every_customer_message_before_first_seller_turn():
|
||||
public = serialize_session(
|
||||
{
|
||||
"id": "session-legacy",
|
||||
"persona_meta": {"locale": "en"},
|
||||
"messages": [
|
||||
{"role": "customer", "text": "SECRET_OPENER_ONE"},
|
||||
{"role": "customer", "text": "SECRET_OPENER_TWO"},
|
||||
{"role": "seller", "text": "Hello"},
|
||||
{"role": "customer", "text": "Public reply"},
|
||||
],
|
||||
}
|
||||
)
|
||||
|
||||
texts = [row["text"] for row in public["messages"]]
|
||||
assert "SECRET_OPENER_ONE" not in texts
|
||||
assert "SECRET_OPENER_TWO" not in texts
|
||||
assert texts[:2] == [
|
||||
"Hi, a customer has started the conversation.",
|
||||
"Hi, a customer has started the conversation.",
|
||||
]
|
||||
assert texts[-1] == "Public reply"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,value",
|
||||
[("name", {"secret": "nested"}), ("profession", ["nested"])],
|
||||
)
|
||||
def test_ready_group_rejects_non_scalar_revealable_persona_fields(field, value):
|
||||
persona = {"id": "persona-1", "name": "Buyer", field: value}
|
||||
group = {
|
||||
"status": "ready",
|
||||
"sales_kit": {"product": "CRM"},
|
||||
"report": {"summary": "ready"},
|
||||
"personas": [persona],
|
||||
}
|
||||
|
||||
assert is_ready_group(group) is False
|
||||
|
||||
|
||||
def test_session_start_fails_closed_beside_malformed_same_scope_status(tmp_path):
|
||||
store = SessionStore(tmp_path)
|
||||
corrupt = store.create(
|
||||
org_id="org-1",
|
||||
user_id="user-1",
|
||||
group_id="group-1",
|
||||
persona_id="persona-1",
|
||||
persona_name="Buyer",
|
||||
)
|
||||
store.sessions.update(corrupt["id"], status="corrupt")
|
||||
|
||||
with pytest.raises(ValueError, match="invalid persisted session state"):
|
||||
store.start(
|
||||
org_id="org-1",
|
||||
user_id="user-1",
|
||||
group_id="group-1",
|
||||
persona_id="persona-1",
|
||||
persona_name="Buyer",
|
||||
)
|
||||
|
||||
assert len(store.sessions.all()) == 1
|
||||
|
||||
|
||||
def test_public_registration_cannot_rearm_super_admin_after_store_depletion(
|
||||
client, user_store
|
||||
):
|
||||
user_store.users.delete("admin")
|
||||
|
||||
first = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": "replacement-one",
|
||||
"password": "replacement-one-password",
|
||||
"email": "replacement-one@example.com",
|
||||
"accepted_terms": True,
|
||||
},
|
||||
)
|
||||
assert first.status_code == 201, first.get_json()
|
||||
assert first.get_json()["user"]["role"] == "user"
|
||||
|
||||
user_store.users.delete("replacement-one")
|
||||
second = client.post(
|
||||
"/api/auth/register",
|
||||
json={
|
||||
"username": "replacement-two",
|
||||
"password": "replacement-two-password",
|
||||
"email": "replacement-two@example.com",
|
||||
"accepted_terms": True,
|
||||
},
|
||||
)
|
||||
assert second.status_code == 201, second.get_json()
|
||||
assert second.get_json()["user"]["role"] == "user"
|
||||
|
||||
|
||||
def test_decode_token_rejects_lifetime_longer_than_configured(user_store, monkeypatch):
|
||||
monkeypatch.setattr(Config, "JWT_EXPIRES_HOURS", 1)
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
forged = jwt.encode(
|
||||
{
|
||||
"sub": "admin",
|
||||
"org_id": "org-default",
|
||||
"role": "super_admin",
|
||||
"auth_version": 0,
|
||||
"iat": now,
|
||||
"exp": now + datetime.timedelta(hours=720),
|
||||
},
|
||||
Config.SECRET_KEY,
|
||||
algorithm=Config.JWT_ALGO,
|
||||
)
|
||||
|
||||
with pytest.raises(AuthError, match="invalid or expired token"):
|
||||
user_store.decode_token(forged)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"secret,bootstrap",
|
||||
[("x" * 32, "strong-bootstrap-password"), ("strong-jwt-secret-0123456789abcdef", "z" * 12)],
|
||||
)
|
||||
def test_production_rejects_low_entropy_repeated_secrets(monkeypatch, secret, bootstrap):
|
||||
monkeypatch.setattr(Config, "APP_ENV", "production")
|
||||
monkeypatch.setattr(Config, "FLASK_DEBUG", False)
|
||||
monkeypatch.setattr(Config, "SECRET_KEY", secret)
|
||||
monkeypatch.setattr(Config, "BOOTSTRAP_ADMIN_PASSWORD", bootstrap)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
Config.validate_runtime_security(require_bootstrap=True)
|
||||
|
||||
|
||||
def test_oauth_same_provider_subject_cannot_create_second_account(
|
||||
client, user_store, monkeypatch
|
||||
):
|
||||
_enable_google(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
oauth_svc,
|
||||
"validate_google_token",
|
||||
lambda _token: ("first-oauth@example.com", "stable-subject", "First"),
|
||||
)
|
||||
first = _oauth(client)
|
||||
assert first.status_code == 200, first.get_json()
|
||||
first_id = first.get_json()["user"]["id"]
|
||||
|
||||
monkeypatch.setattr(
|
||||
oauth_svc,
|
||||
"validate_google_token",
|
||||
lambda _token: ("changed-oauth@example.com", "stable-subject", "Changed"),
|
||||
)
|
||||
second = _oauth(client)
|
||||
|
||||
assert second.status_code == 200, second.get_json()
|
||||
assert second.get_json()["user"]["id"] == first_id
|
||||
assert len([u for u in user_store.users.all() if u["id"].startswith("g_")]) == 1
|
||||
|
||||
|
||||
def test_oauth_refuses_implicit_email_link_without_provider_binding(
|
||||
client, user_store, monkeypatch
|
||||
):
|
||||
_enable_google(monkeypatch)
|
||||
user_store.create_user(
|
||||
org_id="org-default",
|
||||
username="password-user",
|
||||
password="password-user-password",
|
||||
name="Password User",
|
||||
role="user",
|
||||
email="linked@example.com",
|
||||
must_setup=False,
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
oauth_svc,
|
||||
"validate_google_token",
|
||||
lambda _token: ("linked@example.com", "new-subject", "Password User"),
|
||||
)
|
||||
|
||||
response = _oauth(client)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
def test_oauth_does_not_create_missing_default_org(client, user_store, monkeypatch):
|
||||
_enable_google(monkeypatch, org_id="org-missing")
|
||||
monkeypatch.setattr(
|
||||
oauth_svc,
|
||||
"validate_google_token",
|
||||
lambda _token: ("missing-org@example.com", "missing-org-sub", "Missing"),
|
||||
)
|
||||
|
||||
response = _oauth(client)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert user_store.get_org_or_none("org-missing") is None
|
||||
|
||||
|
||||
def test_malformed_consent_state_revokes_existing_token(client, user_store, login):
|
||||
user_store.complete_setup(
|
||||
"admin",
|
||||
"admin-wave2@example.com",
|
||||
"admin-wave2-password",
|
||||
accepted_terms=True,
|
||||
)
|
||||
token = login("admin", "admin-wave2-password")["token"]
|
||||
user_store.users.update("admin", accepted_terms_at="malformed")
|
||||
|
||||
response = client.get("/api/auth/me", headers=_headers(token))
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"field,value",
|
||||
[("tier", {"invalid": "mapping"}), ("recontact", "false")],
|
||||
)
|
||||
def test_ready_group_rejects_explicit_malformed_behavior_traits(field, value):
|
||||
persona = {"id": "persona-1", "name": "Buyer", field: value}
|
||||
group = {
|
||||
"status": "ready",
|
||||
"sales_kit": {"product": "CRM"},
|
||||
"report": {"summary": "ready"},
|
||||
"personas": [persona],
|
||||
}
|
||||
|
||||
assert is_ready_group(group) is False
|
||||
|
||||
|
||||
def test_ready_group_rejects_malformed_intent_tier_alongside_valid_tier():
|
||||
group = {
|
||||
"status": "ready",
|
||||
"sales_kit": {"product": "CRM"},
|
||||
"report": {"summary": "ready"},
|
||||
"personas": [
|
||||
{
|
||||
"id": "persona-1",
|
||||
"name": "Buyer",
|
||||
"tier": "A",
|
||||
"intent_tier": {"invalid": "mapping"},
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
assert is_ready_group(group) is False
|
||||
|
||||
|
||||
def test_oauth_rejects_duplicate_binding_within_same_user(client, user_store, monkeypatch):
|
||||
_enable_google(monkeypatch)
|
||||
monkeypatch.setattr(
|
||||
oauth_svc,
|
||||
"validate_google_token",
|
||||
lambda _token: ("duplicate@example.com", "duplicate-sub", "Duplicate"),
|
||||
)
|
||||
first = _oauth(client)
|
||||
assert first.status_code == 200, first.get_json()
|
||||
user_id = first.get_json()["user"]["id"]
|
||||
binding = {"provider": "google", "subject": "duplicate-sub"}
|
||||
user_store.users.update(user_id, oauth_identities=[binding, dict(binding)])
|
||||
|
||||
response = _oauth(client)
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"secret,bootstrap",
|
||||
[
|
||||
("abcd" * 8, "strong-bootstrap-password"),
|
||||
("strong-jwt-secret-0123456789abcdef", "abcd" * 3),
|
||||
],
|
||||
)
|
||||
def test_production_rejects_low_period_repeated_secrets(
|
||||
monkeypatch, secret, bootstrap
|
||||
):
|
||||
monkeypatch.setattr(Config, "APP_ENV", "production")
|
||||
monkeypatch.setattr(Config, "FLASK_DEBUG", False)
|
||||
monkeypatch.setattr(Config, "SECRET_KEY", secret)
|
||||
monkeypatch.setattr(Config, "BOOTSTRAP_ADMIN_PASSWORD", bootstrap)
|
||||
|
||||
with pytest.raises(RuntimeError):
|
||||
Config.validate_runtime_security(require_bootstrap=True)
|
||||
|
||||
|
||||
def test_unaccepted_terms_rejects_nonempty_consent_timestamp(
|
||||
client, user_store, login
|
||||
):
|
||||
token = login("admin", "pytest-bootstrap-password")["token"]
|
||||
user_store.users.update("admin", accepted_terms_at="malformed")
|
||||
|
||||
response = client.get("/api/auth/me", headers=_headers(token))
|
||||
|
||||
assert response.status_code == 401
|
||||
@@ -4,7 +4,7 @@ from __future__ import annotations
|
||||
from app.services.trainee import analyze_weak_areas
|
||||
|
||||
|
||||
def _session(sid, *, outcome="lost", status="finished", mode="trainee", user="u1", org="org-1", score=20, text=True):
|
||||
def _session(sid, *, outcome="lost", status="finished", mode: object = "trainee", user="u1", org="org-1", score=20, text=True):
|
||||
return {
|
||||
"id": sid,
|
||||
"user_id": user,
|
||||
@@ -60,6 +60,18 @@ def test_weak_areas_return_stable_empty_dimensions_without_sessions():
|
||||
assert all(item["evidence_count"] == 0 for item in result["dimensions"].values())
|
||||
|
||||
|
||||
def test_weak_areas_reject_falsey_malformed_session_modes():
|
||||
result = analyze_weak_areas([
|
||||
_session("valid"),
|
||||
_session("empty-mode", mode=""),
|
||||
_session("false-mode", mode=False),
|
||||
], user_id="u1", org_id="org-1")
|
||||
|
||||
assert result["total_sessions"] == 1
|
||||
assert result["losses"] == 1
|
||||
assert result["top_loss_personas"][0]["persona_id"] == "p-valid"
|
||||
|
||||
|
||||
def test_weak_area_function_does_not_mix_users_when_caller_scopes_input():
|
||||
result = analyze_weak_areas([_session("mine", user="u1")], user_id="u1", org_id="org-1")
|
||||
|
||||
|
||||
@@ -23,7 +23,49 @@ filesystem JSON storage (no SQL). i18n TH/EN. No self-registration (admin provis
|
||||
- **admin** — manages groups/users, sees personas with **secret fields stripped** (IP protection).
|
||||
- **user** (trainee) — trains against personas, own board.
|
||||
|
||||
## 2026-08-25 review checkpoint — exact-current local code gate passed 8/8
|
||||
> All eight independent review scopes now have complete five-key passes with
|
||||
> empty security and logic arrays: ownership/tenant isolation; lifecycle/races;
|
||||
> auth/setup/consent/JWT/OAuth/config; Chat/session/privacy;
|
||||
> analytics/report/export/privacy; frontend Training-to-Practice; frontend-backend
|
||||
> API contracts; and final cross-cutting security/correctness.
|
||||
>
|
||||
> Wave 3 required two remediation rounds. The first bounded analytics scans,
|
||||
> enforced tenant-consistent session/user/group joins, disabled analysis actions
|
||||
> while `analyzing`, and routed completed regular-user chats to `/my/board`. The
|
||||
> second replaced CSV stringification of arbitrary nested persisted values with a
|
||||
> bounded scalar-only contract. Fresh analytics review `deleg_bcb1510f` and
|
||||
> frontend review `deleg_1fed2524` both passed.
|
||||
>
|
||||
> Final Wave 4 `deleg_97eb385c` passed API contracts and cross-cutting review.
|
||||
> Latest evidence: backend **509 passed in 73.27s**; frontend **26/26**; production
|
||||
> build **1775 modules**; isolated Playwright **15/15** across desktop, 320×568,
|
||||
> and 500×768; compileall, source security scan, and `git diff --check` passed.
|
||||
>
|
||||
> Non-blocking follow-ups: parameterize `_csv_cell` edge cases, add malformed API
|
||||
> envelope tests for view consumers, and prevent Playwright from reusing an
|
||||
> unrelated server on port 3000. No live OAuth provider or production runtime was
|
||||
> verified. Branch is `main`; HEAD is
|
||||
> `8a632b5e6a3b67c9acd2787e1a686ac17ae69486`; index is empty. No stage, commit,
|
||||
> push, deploy, reset, or stash occurred; broad user WIP remains untouched.
|
||||
|
||||
## Current state — local code/security gate passed; production-operation gate pending
|
||||
> **2026-08-23 — Demo SaaS:** Implemented and locally verified; not deployed or pushed. Demo accounts use
|
||||
> role `demo` in the dedicated `DEMO_ORG_ID` tenant. Only `POST /api/admin/demo-accounts` (super_admin
|
||||
> only) can provision them; generic user creation and other role paths are blocked. The first successful
|
||||
> password login starts exactly one fixed **30-day UTC trial**; the window is idempotent. A super_admin
|
||||
> can revoke via `POST /api/admin/demo-accounts/<username>/revoke` (DELETE alias), which invalidates
|
||||
> existing tokens. Demo users can access only demo-visibility, `ready` groups in the demo tenant;
|
||||
> backend mutation guards block demo group/persona creation and visibility changes. Creation returns the
|
||||
> username and temporary password once plus SES/webhook delivery status; the password is never
|
||||
> retrievable later or persisted in stores, audit logs, delivery events, or application logs. Configure
|
||||
> `DEMO_ORG_ID` (optionally `DEMO_ORG_NAME`/`DEMO_ORG_SEATS`), the AWS credential chain + `AWS_REGION`
|
||||
> + verified `SES_FROM_EMAIL` (`SES_REPLY_TO` optional), and HTTPS `DEMO_WEBHOOK_URL` +
|
||||
> `DEMO_WEBHOOK_SECRET`. Focused demo tests: **10 passed**; full backend: **358 passed**; frontend
|
||||
> Vitest: **5 passed**; `npm run build`: **passed**. Remaining operational gates are to configure and
|
||||
> verify the SES sender/AWS credentials and webhook secret, test in a restricted environment, and get
|
||||
> operator approval. No live SES/webhook delivery, production operation, deploy, or push has been
|
||||
> performed.
|
||||
> **2026-08-21:** UX/SAAS 12-point redesign (**PUSHED + LIVE, verified 2026-08-21**). Self-registration
|
||||
> (`POST /api/auth/register`, role=user, first-created-user = super_admin), super_admin may now
|
||||
> promote others + is invisible to regular admin, user-created private product groups + admin
|
||||
|
||||
@@ -32,14 +32,17 @@ Informed by MiroFish (CrowdSight engine) + the hermes-brain-and-tools CrowdSight
|
||||
| S4.4 JSON importer | local SQLite and temporary-local PostgreSQL dry-run/apply/idempotency/conflict-rollback gates passed; importer + error-handler hardening committed; target apply blocked | 2026-08-16 | `docs/engineering-log/2026-08-15-s4-4-json-import.md`, `docs/engineering-log/2026-08-15-postgresql-import-gate.md`, `docs/engineering-log/2026-08-16-s4-4-importer-errorhandler-commit.md`, `docs/test-evidence/2026-08-15-postgresql-import.md` | target snapshot checksum/count comparison, retained backup, and operator-approved rollback rehearsal |
|
||||
| UX/UI redesign + marketing website | implemented + locally verified (build clean, 4/4 unit tests, independent review PASS, responsive verified); uncommitted; deploy pending operator approval | 2026-08-19 | `docs/engineering-log/2026-08-19-ux-redesign-and-marketing-site.md`, `website/`, `git diff` | operator approves push (auto-deploys); confirm production `JWT_SECRET` before deploy |
|
||||
| OAuth Google + Facebook login/register | implemented + locally verified (348 backend tests incl. 11 new, frontend build + 4/4 unit, manual security review PASS, no new deps); uncommitted | 2026-08-20 | `docs/engineering-log/2026-08-20-oauth-google-facebook.md`, `backend/app/services/oauth.py`, `backend/app/api/oauth_routes.py`, `backend/tests/test_oauth.py` | operator approves push; set OAUTH_* creds + OAUTH_DEFAULT_ORG in EasyPanel env to enable (disabled by default, fail-closed) |
|
||||
| Training product-to-persona flow + security remediation | exact-current local code/security gate passed 8/8 independent scopes; production-operation gate pending | 2026-08-25 | `docs/engineering-log/2026-08-24-training-security-review.md`, `docs/test-evidence/2026-08-24-training-security-review.md` | operator decides whether to stage/commit/push; live OAuth or production verification remains a separate approval gate |
|
||||
|
||||
## Guardrails
|
||||
- No self-registration; admin provisions users. (Verified: register => 404.)
|
||||
- Public self-registration may create only role `user`; super-admin bootstrap is
|
||||
factory-controlled and cannot be re-armed by deleting users.
|
||||
- One persona = one chat per user (one-shot). Enforced in SessionStore + chat start.
|
||||
- Latent persona fields never leak to trainees pre-result.
|
||||
- LLM credentials live in `.env` only; never logged.
|
||||
|
||||
## Entry index
|
||||
- `2026-08-24-training-security-review.md` — current Training flow/security remediation evidence, review timeout handling, and blocked stage/commit/push gate.
|
||||
- `2026-08-07-build-out.md` — M0–M7 build-out, decisions, verification, current state.
|
||||
- `2026-08-07-security-ux.md` — security hardening (path traversal, IDOR, XSS) + UX/UI polish.
|
||||
- `2026-08-07-auth-gitea.md` — username login + first-time admin setup + Gitea push.
|
||||
|
||||
67
docs/engineering-log/2026-08-23-demo-saas.md
Normal file
67
docs/engineering-log/2026-08-23-demo-saas.md
Normal file
@@ -0,0 +1,67 @@
|
||||
# 2026-08-23 — Demo SaaS account provisioning and restricted trial
|
||||
|
||||
Date: 2026-08-23
|
||||
Status: implemented + locally verified; live delivery, production operation, deployment, and push not performed
|
||||
|
||||
## Scope
|
||||
|
||||
- Added the `demo` role with a dedicated `DEMO_ORG_ID` tenant. Demo accounts can be created only
|
||||
through `POST /api/admin/demo-accounts` by a `super_admin`; generic user creation and direct
|
||||
`role=demo` mutations are blocked.
|
||||
- A demo account starts exactly one fixed **30-day UTC trial** on its first successful password
|
||||
login. The trial timestamps are record-locked and idempotent. `super_admin` can revoke through
|
||||
`POST /api/admin/demo-accounts/<username>/revoke` (with the DELETE alias), deactivating the
|
||||
account and invalidating existing tokens.
|
||||
- Demo users can access only demo-visibility, `ready` groups in the demo tenant. Backend guards
|
||||
block cross-tenant access, group/persona creation, and visibility changes for the `demo` role.
|
||||
- Provisioning returns the generated username and temporary password once, together with SES and
|
||||
authenticated HTTPS webhook delivery status. Delivery is opt-in/best-effort; the temporary
|
||||
password is not retrievable later and is not persisted in user stores, audit logs, delivery
|
||||
events, or application logs.
|
||||
|
||||
## Implementation surface
|
||||
|
||||
These are the implementation modules documented by this entry; this documentation update did not
|
||||
modify code or tests.
|
||||
|
||||
- Backend configuration/wiring: `backend/.env.example`, `backend/app/config.py`,
|
||||
`backend/app/factory.py`, `backend/requirements.txt`, `backend/requirements.lock.txt`.
|
||||
- Identity and API policy: `backend/app/auth/users.py`, `backend/app/api/auth_routes.py`,
|
||||
`backend/app/api/admin_routes.py`, `backend/app/api/group_routes.py`,
|
||||
`backend/app/api/chat_routes.py`, `backend/app/api/me_routes.py`, and
|
||||
`backend/app/api/helpers.py`.
|
||||
- Delivery and storage: `backend/app/services/demo_delivery.py` and
|
||||
`backend/app/services/groups.py`.
|
||||
- Frontend wiring/UI: `frontend/src/api/index.js`, `frontend/src/i18n/index.js`,
|
||||
`frontend/src/store/auth.js`, `frontend/src/router.spec.js`, and the affected views
|
||||
`AdminUsers.vue`, `GroupBuilder.vue`, `GroupEdit.vue`, `MyBoard.vue`, `Personas.vue`,
|
||||
`SessionDetail.vue`, and `Training.vue`.
|
||||
- Focused regression coverage: `backend/tests/test_demo_accounts.py`.
|
||||
|
||||
## Verification evidence
|
||||
|
||||
| Command | Result |
|
||||
|---|---|
|
||||
| `cd backend && uv run pytest -q tests/test_demo_accounts.py` | **10 passed** |
|
||||
| `cd backend && uv run pytest -q` | **358 passed** |
|
||||
| `cd frontend && npm run test:unit` (Vitest) | **5 passed** |
|
||||
| `cd frontend && npm run build` | **passed** |
|
||||
|
||||
## Limitations and remaining operational gates
|
||||
|
||||
- SES delivery was not exercised against AWS. Before operational use, configure a verified
|
||||
`SES_FROM_EMAIL`, `AWS_REGION`, and the normal AWS credential chain (`SES_REPLY_TO` is optional).
|
||||
- Webhook delivery was not exercised against a live receiver. Configure HTTPS `DEMO_WEBHOOK_URL`
|
||||
and `DEMO_WEBHOOK_SECRET`; the secret is required for signed delivery.
|
||||
- Test/local configuration may leave SES and webhook settings blank, in which case delivery is
|
||||
reported as skipped while the one-time API response remains the credential handoff.
|
||||
- The remaining gate is a restricted-environment test with verified SES sender/AWS credentials and
|
||||
webhook secret, followed by operator approval. No live SES/webhook call, production operation,
|
||||
deployment, or push was performed.
|
||||
|
||||
## Documentation updated
|
||||
|
||||
- `docs/HANDOFF.md` — added the current Demo SaaS state, verification totals, required configuration,
|
||||
and explicit operational gates.
|
||||
- `README.md` — added a minimal Demo SaaS roles/permissions/trial note and pointed to
|
||||
`backend/.env.example` for demo/SES/webhook environment configuration.
|
||||
406
docs/engineering-log/2026-08-24-training-security-review.md
Normal file
406
docs/engineering-log/2026-08-24-training-security-review.md
Normal file
@@ -0,0 +1,406 @@
|
||||
# 2026-08-24 — Training flow and security review checkpoint
|
||||
|
||||
## Goal
|
||||
|
||||
Move the regular-user journey to:
|
||||
|
||||
`Training → สินค้าของฉัน → เลือกสินค้า → ดู Personas → เริ่มฝึก`
|
||||
|
||||
while preserving the admin flow, restricting demo users, enforcing tenant/owner
|
||||
boundaries, allowing safe retry of `draft`/`failed` products, and preventing
|
||||
persona/credential leakage.
|
||||
|
||||
## Remediation recorded
|
||||
|
||||
- Regular users create products through their own route; demo users are blocked
|
||||
in both frontend navigation and backend authorization.
|
||||
- Personas links are shown to regular users only for `ready` products.
|
||||
- Owners retain visibility of their private `draft`/`failed` products; admins
|
||||
can retry analysis through the edit flow.
|
||||
- Cross-tenant group IDs are indistinguishable from missing IDs (`404`), and
|
||||
API responses use `is_owned` rather than exposing `owner_user_id`/`creator_id`.
|
||||
- Protected routes require both completed setup (`must_setup is False`) and
|
||||
explicit Terms consent (`accepted_terms is True`); missing markers fail closed.
|
||||
- Session/debrief/persona responses use closed allowlists; provider/storage
|
||||
errors are mapped to stable client-safe messages.
|
||||
- Owner markers and private visibility now form one fail-closed invariant;
|
||||
normal create/update paths reject unowned-private and malformed-owner records,
|
||||
while legacy malformed records are excluded from access and indexes.
|
||||
- Non-ready groups remain visible to their owner/admin for recovery, but their
|
||||
persona payloads/counts and direct persona access are empty/blocked until
|
||||
`ready`, including admin responses.
|
||||
- Owner products stuck in `analyzing` remain reopenable and the Training and
|
||||
admin edit UIs expose the recovery action instead of dead-ending.
|
||||
- Judge-only evaluator reasoning and provider paths are excluded from the
|
||||
persona role-play prompt; only bounded role-play state is retained.
|
||||
- Transcript serialization keeps seller/customer messages plus an exact
|
||||
allowlist of server-generated `⏳` scene notes, redacts legacy initial openers,
|
||||
and transcript-only debriefing receives seller messages only.
|
||||
- `finishSetup()` remains setup-required unless the API returns both
|
||||
`must_setup: false` and `accepted_terms: true`.
|
||||
- JWT lifetimes now use a positive 1–720 hour allowlist and token issuance
|
||||
rejects invalid values before encoding; decode also rejects non-positive `exp`.
|
||||
- Group visibility now uses one exact allowlist; malformed/non-string/falsey
|
||||
persisted values are rejected at listing and ID-addressed authorization.
|
||||
|
||||
## Current verification
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| `cd backend && ./.venv/bin/python -m pytest -q` | **457 passed** |
|
||||
| `cd frontend && npm run test:unit` | **18 passed** |
|
||||
| `cd frontend && npm run build` | **passed** |
|
||||
| `cd frontend && npm run test:e2e` | **15 passed** across desktop, 320×568, and 500×768 |
|
||||
| `cd backend && python3 -m compileall -q app tests` | **passed** |
|
||||
| `git diff --check` | **passed** |
|
||||
| Added-line static security scan | **0** shell-injection, eval/exec, pickle, or formatted-SQL matches; hardcoded-secret pattern matched test-only fixture literals only, with **0 production-file matches** |
|
||||
|
||||
The Playwright run emitted Vite proxy connection-refused messages for
|
||||
`/api/auth/oauth/config` because no backend was running for the fixture server.
|
||||
The tests still passed; this checkpoint does not claim live-provider or
|
||||
production verification.
|
||||
|
||||
## Independent review gate
|
||||
|
||||
`deleg_6017c670` was dispatched against the current tree with four bounded
|
||||
scopes. All four reviewers timed out after roughly ten minutes and returned no
|
||||
complete five-key JSON payload. This is **no verdict**, not a failed review and
|
||||
not approval. Fresh batch `deleg_ea020089` then targeted the post-hardening
|
||||
tree, but its auth, tenant/group, and frontend scopes timed out at 600 seconds
|
||||
and its integration task was interrupted. It returned no complete five-key
|
||||
verdict and is also **no verdict, not approval**. Replacement integration review
|
||||
`deleg_513b1aa3` also timed out at 600 seconds with no complete five-key payload;
|
||||
it is **no verdict, not approval**. The gate remains closed until replacement
|
||||
scopes return complete, schema-valid current-tree verdicts with `passed: true`:
|
||||
Fresh batch `deleg_5c88269e` also targeted the post-hardening tree, but its
|
||||
delegation owner exited before recording a terminal result. It is
|
||||
unknown/no-verdict and cannot affect the gate.
|
||||
Bounded batch `deleg_fc7b3f38` returned one valid negative auth verdict whose findings were remediated, one valid positive tenant verdict, and no usable frontend/integration verdicts, so it did not approve the batch. Latest batch `deleg_73452135` timed out in all four scopes after 600 seconds with no complete five-key payload; it is **no verdict, not approval**. Replacement batch `deleg_191e6862` completed with one valid negative auth verdict, one valid positive frontend verdict, one timeout, and one valid negative lifecycle verdict; its findings were remediated and it did not approve the tree. Fresh batch `deleg_71b85ab4` completed with one valid frontend pass and three timeouts (auth, backend visibility/privacy, and lifecycle); the timeouts are no verdicts, so this batch did not approve the tree. Replacement batch `deleg_b4bdbacb` completed with four valid negative current-tree verdicts; all cited findings were remediated locally and re-verified by focused/full automated tests. Earlier batch `deleg_40657086` produced one valid positive auth verdict, but its group, chat/privacy, and lifecycle scopes were interrupted without complete five-key payloads; it is incomplete/no-approval. Interrupted batch `deleg_735d9a1f` produced no complete five-key payload in any scope, so it is **no verdict, not approval** and is superseded by the current batch. Fresh batch `deleg_4c6a7118` is now running against the exact current tree with eight bounded scopes and an exact five-key schema. It includes a regression gate for the corrected super-admin CSV privacy bypass and malformed active-session recovery. No stage, commit, or push is allowed until every required scope returns a complete `passed: true` verdict with empty blocking arrays.
|
||||
|
||||
Older batch `deleg_3fdefb96` completed before the latest hardening and is
|
||||
superseded; its findings are not approval or a current-tree verdict.
|
||||
Batch `deleg_b25d8482` likewise completed from a pre-hardening snapshot and is
|
||||
superseded; only a fresh current-tree verdict can affect this gate.
|
||||
Batch `deleg_f0a2aa91` also predates the latest hardening and is superseded;
|
||||
its findings do not close or replace the current-tree gate.
|
||||
Batch `deleg_08c54d5a` was also dispatched before the latest hardening. Its
|
||||
auth task was interrupted with no verdict; its tenant and frontend tasks are
|
||||
superseded and cannot affect the current-tree gate.
|
||||
Batch `deleg_cd09ba23` exited before recording a terminal result. It is an
|
||||
unknown/no-verdict batch, predates the current hardening, and cannot affect the
|
||||
current-tree gate.
|
||||
Batch `deleg_abeccba2` was dispatched before the latest hardening; all three
|
||||
tasks were interrupted without complete JSON verdicts and cannot affect the
|
||||
current-tree gate.
|
||||
Batch `deleg_bfac6f8b` exited before recording a terminal result. It is an
|
||||
unknown/no-verdict pre-hardening batch and cannot affect the current-tree gate.
|
||||
Batch `deleg_5f3f325f` exited before recording a terminal result. It is an
|
||||
unknown/no-verdict pre-hardening batch and cannot affect the current-tree gate.
|
||||
|
||||
```json
|
||||
{
|
||||
"passed": true,
|
||||
"security_concerns": [],
|
||||
"logic_errors": [],
|
||||
"suggestions": [],
|
||||
"summary": "..."
|
||||
}
|
||||
```
|
||||
|
||||
## Git and safety state
|
||||
|
||||
- Branch: `main`
|
||||
- HEAD: `8a632b5e6a3b67c9acd2787e1a686ac17ae69486`
|
||||
- Index: clean; no staged paths
|
||||
- Existing user WIP remains untouched
|
||||
- No secrets were read, stored, staged, committed, pushed, or deployed
|
||||
|
||||
## Next action
|
||||
|
||||
Validate exact-current re-check `deleg_0456c47d`. If both exact five-key verdicts
|
||||
pass with empty blocking arrays, dispatch the remaining six scopes as three queued
|
||||
waves of at most two heavy reviewers. If either fails, stop the queue and repeat
|
||||
test-first remediation only for that scope. Commit/push/deploy still require
|
||||
explicit operator direction.
|
||||
|
||||
## 2026-08-25 Wave 2 Chat/session and auth remediation
|
||||
|
||||
Independent batch `deleg_ead5db71` returned two complete negative verdicts.
|
||||
The Chat/session reviewer reproduced three current-tree failures: a second
|
||||
pre-seller customer opener leaked through public serialization, malformed
|
||||
prompt-facing Persona fields could be published as ready, and a corrupt
|
||||
same-scope session status allowed creation of a second session. The auth reviewer
|
||||
reproduced repeatable public super-admin promotion after deleting all users,
|
||||
unbounded forged JWT lifetimes, trivially repeated production secrets, unstable
|
||||
OAuth subject/email identity, implicit OAuth tenant creation, and malformed
|
||||
consent state accepted by `/auth/me`.
|
||||
|
||||
The remediation was test-first. `backend/tests/test_wave2_review_regressions.py`
|
||||
failed all **12** new regressions before production changes and then passed all
|
||||
**12**. The current implementation now:
|
||||
|
||||
- redacts every customer message before the first seller turn;
|
||||
- validates bounded, finite, structurally canonical prompt-facing Persona data;
|
||||
- rejects malformed same-scope session mode/status/outcome before resume/create;
|
||||
- keeps public registration permanently at role `user`; factory bootstrap is the
|
||||
only super-admin initialization path;
|
||||
- bounds decoded JWT lifetime to configured `JWT_EXPIRES_HOURS` and requires
|
||||
positive finite `iat`/`exp`;
|
||||
- rejects trivially repeated production JWT/bootstrap secrets;
|
||||
- persists atomic provider+subject OAuth bindings, rejects email-only auto-link,
|
||||
and requires a pre-provisioned active default organization; and
|
||||
- rejects inconsistent consent/setup state and malformed consent timestamps.
|
||||
|
||||
Compatibility fixes narrowed Persona validation to bounded recursive JSON while
|
||||
retaining strict scalar revealable fields, updated stale tests that encoded the
|
||||
retired email auto-link/first-user promotion behavior, and kept malformed state
|
||||
fail-closed. Current automated evidence is **496 backend tests passed in 71.80s**,
|
||||
`compileall` passed, `git diff --check` passed, and the added-line secret/dangerous-
|
||||
execution scan returned **0 findings**. No live OAuth provider verification is
|
||||
claimed.
|
||||
|
||||
Re-review `deleg_6155669d` returned two complete negative verdicts. Chat/session
|
||||
still silently normalized malformed explicit `tier` and `recontact`; auth still
|
||||
accepted duplicate same-user OAuth bindings, low-period repeated secrets, and a
|
||||
non-null malformed consent timestamp while Terms were unaccepted. Six additional
|
||||
RED assertions reproduced all four classes. The remediation validates explicit
|
||||
Persona behavior fields before defaults/coercion, rejects every repeated OAuth
|
||||
identity pair, detects exact repeated secret periods, and enforces a canonical
|
||||
unaccepted-consent timestamp of null/absent.
|
||||
|
||||
Re-review `deleg_1e21e60c` approved the auth/setup/consent/JWT/OAuth/config
|
||||
scope with an exact five-key pass after **226 focused** and **502 full** tests.
|
||||
It found one remaining Chat-only alias-validation defect: malformed explicit
|
||||
`intent_tier` was skipped when a valid `tier` coexisted. A dedicated RED test
|
||||
reproduced the acceptance; the guard now validates every explicitly present
|
||||
alias before normalization.
|
||||
|
||||
Exact-current evidence after that narrow patch: focused Wave-2 file **19 passed
|
||||
in 1.39s**, full backend **503 passed in 71.89s**, compileall and
|
||||
`git diff --check` passed, and the added-line scan returned **0 findings**.
|
||||
Incremental current-tree Chat re-review `deleg_8c71eff4` returned an exact
|
||||
five-key pass with empty blocking arrays. Its live probes confirmed malformed
|
||||
explicit `intent_tier` fails closed alone and beside a valid `tier`, while
|
||||
canonical nested Persona data remains accepted. The reviewer independently ran
|
||||
**136/136** bounded Chat/session/privacy tests, **19/19** Wave-2 regressions,
|
||||
four targeted tests, compileall, and diff checks. At that historical checkpoint,
|
||||
Wave 2 was closed and the overall gate was **4/8**; Wave 3 batch
|
||||
`deleg_1733a8bf` then reviewed analytics/privacy and frontend with exactly two
|
||||
read-only reviewers while Wave 4 remained queued. The final state is recorded
|
||||
in the Wave 3–4 completion section below.
|
||||
|
||||
## Wave 1 current-tree review and remediation
|
||||
|
||||
`deleg_4d48caf2` returned two schema-valid negative verdicts rather than timing
|
||||
out. Ownership review proved that `super_admin` could cross the owner/private
|
||||
boundary in group listings, ID-addressed reads, report/Persona reads, and chat.
|
||||
Lifecycle review proved three independent gaps: privileged malformed-ready reads,
|
||||
unvalidated status/transitions, and stale admin variant publication after source
|
||||
replacement during generation.
|
||||
|
||||
The fixes were test-driven:
|
||||
|
||||
- Added `backend/tests/test_review_wave1_regressions.py`; its initial run failed.
|
||||
- Excluded owner-private records from super-admin listing and authorization in
|
||||
both group and chat route policy implementations.
|
||||
- Added `GROUP_STATUSES` and explicit transition validation to `GroupStore`.
|
||||
- Validated structurally ready artifacts before any role receives a persisted
|
||||
ready group.
|
||||
- Revalidated the source persona identity under the record lock before appending
|
||||
an admin variant.
|
||||
- Updated stale compatibility tests that previously treated malformed-ready
|
||||
records as readable.
|
||||
|
||||
Verification after compatibility remediation:
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| focused Wave 1 + related suites | **125 passed** |
|
||||
| backend full suite | **471 passed in 68.53s** |
|
||||
| frontend unit | **20 passed in 6 files** |
|
||||
| frontend production build | **passed; 1775 modules transformed** |
|
||||
| Playwright desktop + 320×568 + 500×768 | **15 passed in 3.6s** |
|
||||
| compileall / `git diff --check` | **passed / passed** |
|
||||
| added-line security scan | **0 findings** in checked secret, shell, SQL, and debug patterns |
|
||||
|
||||
Remediation re-check `deleg_8d258a4f` was dispatched with exactly two heavy
|
||||
reviewers. The six untouched scopes remain queued. No stage, commit, push, deploy,
|
||||
reset, stash, credential, or permission action was performed.
|
||||
|
||||
## 2026-08-25 second lifecycle remediation
|
||||
|
||||
`deleg_8d258a4f` returned two complete five-key verdicts. Ownership/tenant
|
||||
isolation passed with empty blocking arrays. Lifecycle/races/readiness/variants
|
||||
failed on five confirmed classes: malformed-ready privileged indexes,
|
||||
`publish_analysis()` transition bypass, duplicate Persona IDs, stale same-ID admin
|
||||
variant publication, and missing trainee source revalidation.
|
||||
|
||||
The second remediation was test-first. Six new assertions failed before the patch.
|
||||
The service now excludes malformed lifecycle records from role indexes, publishes
|
||||
analysis only from `analyzing`, and rejects duplicate Persona IDs. Both admin and
|
||||
trainee variant paths now re-authorize and compare the complete source Persona,
|
||||
sales kit, and safe input snapshot under the source-group lock before publication.
|
||||
A stale board expectation was updated to match the canonical fail-closed contract.
|
||||
|
||||
Current evidence:
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| focused lifecycle/remediation set | **69 passed in 10.90s** |
|
||||
| backend full suite | **477 passed in 69.17s** |
|
||||
| compileall (`app tests`) / `git diff --check` | **passed / passed** |
|
||||
| added-line static scan | **0 production findings**; 3 password matches are test fixtures |
|
||||
|
||||
Because the patch touched the shared visibility boundary, both Wave 1 scopes were
|
||||
re-dispatched against the exact current tree as `deleg_0456c47d`, with two
|
||||
read-only reviewers and no overlapping review wave. The other six scopes remain
|
||||
queued. No stage, commit, push, deploy, reset, stash, credential, or permission
|
||||
action was performed.
|
||||
|
||||
## 2026-08-25 third Wave 1 remediation
|
||||
|
||||
`deleg_0456c47d` returned two complete five-key negative verdicts. Ownership
|
||||
failed because role-first authorization denied a private product to its own user
|
||||
after promotion to admin or super-admin. Lifecycle failed because the variant
|
||||
snapshot omitted visibility and had no monotonic signal for a structurally
|
||||
identical reanalysis.
|
||||
|
||||
Five RED regression instances reproduced the exact gaps. The current policy now
|
||||
recognizes a valid same-tenant owner before applying privileged non-owner denial,
|
||||
while malformed owner records retain their prior fail-closed 403/404 contracts.
|
||||
Privileged-owner listing uses the closed owner envelope (`is_owned`) rather than
|
||||
exposing `owner_user_id`. Every successful analysis publication increments an
|
||||
`analysis_revision`; variant generation snapshots and revalidates both that
|
||||
revision and visibility under the source lock for admin and trainee paths.
|
||||
|
||||
Current evidence:
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| RED Wave 1 regression file | **5 expected failures; 17 passed** |
|
||||
| focused lifecycle/ownership/race set | **74 passed in 12.12s** |
|
||||
| malformed-owner contract + Wave 1 set | **24 passed in 3.55s** |
|
||||
| backend full suite | **482 passed in 70.14s** |
|
||||
| compileall (`app tests`) / `git diff --check` | **passed / passed** |
|
||||
| added-line static scan | **0 findings** |
|
||||
|
||||
Fresh exact-current re-review `deleg_a5931b0d` is pending with exactly two
|
||||
read-only reviewers. The six untouched scopes remain queued; no overlapping wave
|
||||
was launched. No stage, commit, push, deploy, reset, stash, credential, or
|
||||
permission action was performed.
|
||||
|
||||
## 2026-08-25 promoted-owner response and frontend remediation
|
||||
|
||||
`deleg_a5931b0d` returned two complete five-key verdicts. Lifecycle/races passed
|
||||
with empty blocking arrays. Ownership failed because promoted owners received
|
||||
`is_owned=false` from detail/analyze serialization, their private products were
|
||||
hidden by `Training.vue`, and the router denied an admin owner from Personas.
|
||||
|
||||
The ownership gaps were reproduced before the patch: backend Wave 1 had **2
|
||||
expected failures**, and the frontend Training/router slice had **3 expected
|
||||
failures**. Serialization now derives ownership from the canonical valid
|
||||
owner-private predicate rather than current role. Training renders a privileged
|
||||
owner's own-product section when it contains products, and the router admits a
|
||||
non-demo canonical owner before applying privileged shared-product policy.
|
||||
|
||||
Current-tree evidence after remediation:
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| backend promoted-owner/Wave 1 regressions | **22 passed in 3.17s** |
|
||||
| frontend Training + router regressions | **17 passed in 2 files** |
|
||||
| backend full suite | **482 passed in 70.11s** |
|
||||
| frontend full unit suite | **24 passed in 6 files** |
|
||||
| frontend production build | **passed; 1775 modules transformed** |
|
||||
| isolated Sales Trainer E2E | **15 passed in 3.8s; desktop, 320×568, 500×768** |
|
||||
| compileall / `git diff --check` / added-line scan | **passed / passed / 0 findings** |
|
||||
|
||||
The initial E2E attempt reused port 3000 and tested an unrelated CrowdSight Vite
|
||||
server; the served title proved the collision. No product change was made for
|
||||
that infrastructure failure. An isolated Sales Trainer server on port 3001 passed
|
||||
all 15 cases and was then stopped. Fresh ownership-only reviewer
|
||||
`deleg_457c2d44` is pending with one read-only agent. Lifecycle remains approved;
|
||||
the remaining six scopes stay queued. No stage, commit, push, deploy, reset,
|
||||
stash, credential, or permission action was performed.
|
||||
|
||||
## 2026-08-25 cross-tenant promoted-owner remediation
|
||||
|
||||
Fresh ownership reviewer `deleg_457c2d44` reproduced a blocking stale-record
|
||||
path: a promoted super-admin could list, read, and start chat against a private
|
||||
record in another tenant when that record reused the actor's user ID. The prior
|
||||
owner predicate validated identity and private visibility but allowed the
|
||||
super-admin role to bypass tenant equality.
|
||||
|
||||
Two parameterized regressions covered a different valid tenant and an invalid
|
||||
empty tenant. Both failed before production changes. A single canonical helper
|
||||
now requires a valid matching actor/group tenant together with owner ID and
|
||||
private visibility. List serialization, group authorization, `is_owned`, and
|
||||
chat authorization use that policy. Cross-tenant super-admin access remains
|
||||
available only for ownerless shared records.
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| RED stale/invalid-tenant owner regressions | **2 expected failures** |
|
||||
| canonical + stale-tenant focused slice | **4 passed in 1.43s** |
|
||||
| ownership/security focused set | **125 passed in 11.52s** |
|
||||
| backend full suite | **484 passed in 70.52s** |
|
||||
| compileall / `git diff --check` / added-line scan | **passed / passed / 0 findings** |
|
||||
|
||||
The attempted focused command naming nonexistent `test_training_flow.py` ran
|
||||
zero tests and is not evidence; it was replaced by the verified existing-file
|
||||
command above. `deleg_457c2d44` is superseded by this code patch. Fresh
|
||||
ownership-only reviewer `deleg_85d91881` is pending with one read-only agent;
|
||||
the six remaining scopes are still queued. No stage, commit, push, deploy,
|
||||
reset, stash, credential, or permission action was performed.
|
||||
|
||||
## 2026-08-25 Wave 3–4 completion — eight-scope code gate passed
|
||||
|
||||
Wave 3 initially returned two complete negative verdicts in `deleg_1733a8bf`.
|
||||
The analytics scope found unbounded personal/admin store scans and a missing
|
||||
session-user-tenant consistency check. The frontend scope found duplicate analysis
|
||||
actions while status was `analyzing` and a completed regular-user chat link that
|
||||
routed through `/` instead of `/my/board`. All four findings were reproduced and
|
||||
remediated with focused regressions, bounded shared scan accounting, tenant-safe
|
||||
joins, disabled in-progress actions, and role-aware result navigation.
|
||||
|
||||
Fresh Wave 3 batch `deleg_1fed2524` approved the frontend scope but found one
|
||||
remaining analytics export leak: arbitrary nested persisted values were passed to
|
||||
`str()` and emitted into CSV. A RED sentinel regression reproduced leakage from
|
||||
`persona_name`, `scenario`, `created_at`, and a malformed username. `_csv_cell`
|
||||
now exports only bounded strings and finite integer/float scalars; booleans,
|
||||
composites, non-finite numbers, and unsupported values become empty cells.
|
||||
|
||||
Post-fix evidence:
|
||||
|
||||
| Check | Result |
|
||||
|---|---|
|
||||
| nested-value CSV regression | **1 passed in 0.63s** |
|
||||
| focused analytics/export/privacy slice | **39 passed, 146 deselected in 7.88s** |
|
||||
| backend full suite | **509 passed in 73.27s** |
|
||||
| frontend full unit suite | **26 passed** |
|
||||
| frontend production build | **passed; 1775 modules transformed** |
|
||||
| isolated Playwright desktop + 320×568 + 500×768 | **15 passed** |
|
||||
| compileall / `git diff --check` / source security scan | **passed / passed / 0 findings** |
|
||||
|
||||
Analytics re-review `deleg_bcb1510f` returned an exact five-key pass with empty
|
||||
security and logic arrays. It independently confirmed scalar-only CSV handling,
|
||||
shared scan budgets, and tenant-consistent session/user/group joins. Wave 3 then
|
||||
closed at **6/8** approved scopes.
|
||||
|
||||
Final Wave 4 batch `deleg_97eb385c` used exactly two concurrent read-only agents:
|
||||
|
||||
- Scope 7/8 frontend-backend API contracts: **passed**, empty blocking arrays;
|
||||
reviewer verification included **26/26** frontend tests and **81/81** focused
|
||||
backend contract/security tests.
|
||||
- Scope 8/8 bounded cross-cutting security/correctness: **passed**, empty blocking
|
||||
arrays; backend, frontend, build, compileall, diff check, and isolated
|
||||
three-viewport E2E all passed.
|
||||
|
||||
The exact-current local code/security gate is therefore **8/8 independently
|
||||
approved**. Non-blocking follow-ups are to add direct `_csv_cell` parameterization,
|
||||
expand malformed frontend-envelope tests, and prevent Playwright from reusing an
|
||||
unrelated server on port 3000. These do not reopen the gate. No live OAuth-provider
|
||||
verification, production runtime check, stage, commit, push, deploy, reset, stash,
|
||||
credential, or permission action is claimed. Branch remains `main`; HEAD remains
|
||||
`8a632b5e6a3b67c9acd2787e1a686ac17ae69486`; the index is empty and the broad
|
||||
existing working tree remains intentionally dirty.
|
||||
157
docs/test-evidence/2026-08-24-training-security-review.md
Normal file
157
docs/test-evidence/2026-08-24-training-security-review.md
Normal file
@@ -0,0 +1,157 @@
|
||||
# Test evidence — 2026-08-24 Training flow/security remediation
|
||||
|
||||
## Scope
|
||||
|
||||
Current uncommitted Training → My Products → Personas → Practice flow,
|
||||
ownership/tenant isolation, lifecycle recovery, session-mode hardening,
|
||||
analytics/export privacy, frontend contracts, and Wave 1 reviewer remediation.
|
||||
|
||||
## Current-tree automated checks
|
||||
|
||||
- Backend: `cd backend && ./.venv/bin/python -m pytest -q` → **484 passed in 70.52s**.
|
||||
- Latest focused lifecycle/ownership/race set → **74 passed in 12.12s**.
|
||||
- Targeted malformed-owner contract + Wave 1 regression set → **24 passed in 3.55s**.
|
||||
- Frontend unit: `cd frontend && npm run test:unit` → **24 passed in 6 files**.
|
||||
- Frontend production build: `cd frontend && npm run build` → **passed**
|
||||
(`1775` modules transformed).
|
||||
- Browser journeys: `E2E_BASE_URL=http://127.0.0.1:3001 npm run test:e2e` →
|
||||
**15 passed in 3.8s** across desktop, 320×568, and 500×768. The first attempt
|
||||
correctly failed because Playwright reused port 3000, which was serving CrowdSight;
|
||||
rerunning against an isolated Sales Trainer server on port 3001 passed every case.
|
||||
- Frontend readiness: `curl -fsS http://127.0.0.1:3001/` → **passed**;
|
||||
temporary process `proc_3697d2f91f57` was killed after E2E.
|
||||
- Python compilation: `cd backend && ./.venv/bin/python -m compileall -q app tests` → **passed**.
|
||||
- Whitespace validation: `git diff --check` → **passed**.
|
||||
- Added-line static scan on the latest lifecycle slice: **0 production findings**
|
||||
for hardcoded secrets, shell execution, eval/exec, unsafe pickle, formatted SQL,
|
||||
and debug leftovers. Three password-pattern matches were test fixtures.
|
||||
- Post-`deleg_457c2d44` stale-tenant ownership regressions failed twice before
|
||||
remediation, then the focused ownership/security set passed **125 tests in
|
||||
11.52s**. Canonical private ownership now requires a valid matching tenant
|
||||
together with owner identity, including for promoted super-admins.
|
||||
- `ruff`, `mypy`, `eslint`, and `tsc` are unavailable in this environment; no
|
||||
result is claimed for those tools.
|
||||
|
||||
## Wave 1 independent findings and remediation
|
||||
|
||||
Batch `deleg_4d48caf2` returned two complete, schema-valid negative verdicts:
|
||||
|
||||
1. Ownership/tenant isolation: `super_admin` could list or ID-address owner-private
|
||||
products and start chat against them.
|
||||
2. Lifecycle/readiness/races: privileged reads trusted malformed persisted `ready`
|
||||
state, lifecycle status values/transitions were not validated at the service
|
||||
boundary, and admin variant generation could append a stale variant after
|
||||
concurrent source-persona replacement.
|
||||
|
||||
Current-tree remediation:
|
||||
|
||||
- Owner-private products are excluded from super-admin listings and denied in
|
||||
group and chat authorization; explicitly shared cross-tenant administration
|
||||
remains separate.
|
||||
- `ready` records fail closed for every role unless sales kit, report, and
|
||||
canonical Personas are structurally valid.
|
||||
- `GroupStore` now allowlists lifecycle states and explicit permitted transitions,
|
||||
including `failed → analyzing` retry and `ready → analyzing` reanalysis.
|
||||
- Variant publication revalidates both source status and the exact source persona
|
||||
under the group record lock after LLM generation.
|
||||
- Privileged indexes exclude malformed `ready` and invalid-status records;
|
||||
`publish_analysis()` accepts only persisted `analyzing` groups.
|
||||
- Canonical Persona normalization rejects duplicate IDs, and variant publication
|
||||
compares the complete source Persona, sales kit, and safe input snapshot for
|
||||
both admin and trainee paths.
|
||||
- Added `backend/tests/test_review_wave1_regressions.py` and updated stale tests
|
||||
that previously accepted malformed-ready artifacts.
|
||||
|
||||
## Independent review gate
|
||||
|
||||
Remediation re-check batch `deleg_8d258a4f` returned complete verdicts:
|
||||
|
||||
- Ownership/tenant isolation: **passed** with empty blocking arrays.
|
||||
- Lifecycle/races/readiness/variants: **failed** with confirmed malformed-index,
|
||||
publication-transition, duplicate-ID, and stale same-ID/admin+trainee variant gaps.
|
||||
|
||||
Batch `deleg_0456c47d` then returned two complete negative verdicts: privileged
|
||||
owners lost their own private products after promotion; source visibility was not
|
||||
snapshotted; and structurally identical reanalysis was not detectable. Five RED
|
||||
regressions reproduced those gaps. The current remediation gives valid owners
|
||||
precedence without weakening malformed/non-owner denial, snapshots visibility,
|
||||
and increments/revalidates monotonic `analysis_revision` on every publication.
|
||||
Fresh exact-current re-review `deleg_a5931b0d` is pending with two reviewers and
|
||||
no overlapping wave. The remaining six scopes are queued and not dispatched.
|
||||
|
||||
The gate remains **closed** until all eight current-tree scopes return complete,
|
||||
schema-valid verdicts with `passed: true`, `security_concerns: []`, and
|
||||
`logic_errors: []`. Interrupted, timed-out, truncated, malformed, stale, or partial
|
||||
verdicts do not approve the tree. Any source remediation makes earlier verdicts
|
||||
stale for affected scopes.
|
||||
|
||||
## Harness limitation
|
||||
|
||||
The Playwright fixture server may emit Vite proxy `ECONNREFUSED` messages for
|
||||
`/api/auth/oauth/config` because no Flask backend or OAuth provider is running.
|
||||
This evidence is not a live-provider, authenticated production, or deployment
|
||||
smoke test.
|
||||
|
||||
## Wave 2 remediation evidence — 2026-08-25
|
||||
|
||||
- Failed independent review: `deleg_ead5db71` returned complete negative
|
||||
Chat/session and auth/setup/JWT/OAuth/config verdicts with live local probes.
|
||||
- RED: `tests/test_wave2_review_regressions.py` → **12 expected failures** before
|
||||
production remediation.
|
||||
- GREEN: the same focused file → **12 passed in 1.16s**.
|
||||
- Focused compatibility run: **197 passed / 1 stale fixture failure**, followed
|
||||
by fixture correction; the preceding broader attempt was **157 passed / 29
|
||||
failures**, dominated by one over-strict Persona validator and stale policy
|
||||
expectations, and is not reported as a passing gate.
|
||||
- Full backend: `cd backend && PYTHONPATH= .venv/bin/pytest -q` → **496 passed in
|
||||
71.80s**.
|
||||
- Python compilation and `git diff --check`: **passed**.
|
||||
- Added-line hardcoded-secret/dangerous-execution scan: **0 findings**.
|
||||
- Re-review `deleg_6155669d`: two complete negative verdicts. It reproduced
|
||||
malformed explicit Persona trait coercion plus three auth gaps: same-user
|
||||
duplicate OAuth bindings, low-period repeated secrets, and malformed timestamp
|
||||
state while Terms were unaccepted.
|
||||
- Second RED cycle: **6 expected failures / 12 existing passes** in the focused
|
||||
file before production remediation.
|
||||
- Second GREEN cycle: focused Wave-2 file → **18 passed in 1.40s**.
|
||||
- Latest full backend → **502 passed in 72.06s**; compileall, diff check,
|
||||
Markdown fence check, and added-line scan all passed with **0 scan findings**.
|
||||
- Re-review `deleg_1e21e60c`: auth/setup/consent/JWT/OAuth/config **passed**
|
||||
with exact empty blocking arrays after **226 focused** and **502 full** tests;
|
||||
Chat remained blocked by malformed explicit `intent_tier` coexisting with a
|
||||
valid `tier`.
|
||||
- Third Chat RED cycle: the dedicated coexistence regression failed as expected.
|
||||
- Third Chat GREEN cycle: focused Wave-2 file → **19 passed in 1.39s**; latest
|
||||
full backend → **503 passed in 71.89s**.
|
||||
- Latest compileall, diff check, and added-line scan passed with **0 findings**.
|
||||
- Incremental current-tree Chat re-review `deleg_8c71eff4`: **passed** with
|
||||
exact empty blocking arrays. Independent checks: **136/136** bounded
|
||||
Chat/session/privacy tests, **19/19** Wave-2 regressions, four targeted tests,
|
||||
compileall, and diff checks.
|
||||
- Wave 3 first review `deleg_1733a8bf`: both scopes failed and were remediated.
|
||||
Analytics required shared bounded scans and tenant-consistent user/session/group
|
||||
joins. Frontend required disabled `analyzing` actions and regular-user
|
||||
completed-chat navigation to `/my/board`.
|
||||
- Wave 3 re-review `deleg_1fed2524`: frontend **passed**; analytics found nested
|
||||
persisted values could be stringified into CSV. A sentinel regression failed
|
||||
before the patch and then passed after scalar-only `_csv_cell` handling.
|
||||
- Latest analytics evidence: sentinel regression **1 passed in 0.63s**; focused
|
||||
slice **39 passed, 146 deselected in 7.88s**; full backend **509 passed in
|
||||
73.27s**; compileall and `git diff --check` passed.
|
||||
- Analytics re-review `deleg_bcb1510f`: **passed** with exact empty blocking arrays.
|
||||
- Latest frontend evidence: **26/26** unit tests, production build with **1775
|
||||
modules transformed**, and **15/15** isolated E2E journeys across desktop,
|
||||
320×568, and 500×768.
|
||||
- Final Wave 4 `deleg_97eb385c`: API-contract scope **passed** with **26/26**
|
||||
frontend and **81/81** focused backend checks; final cross-cutting scope also
|
||||
**passed**. Both exact verdicts had empty security and logic arrays.
|
||||
- Independent local code/security gate: **8/8 scopes passed**.
|
||||
- No live OAuth provider, production runtime, deployment, stage, commit, push,
|
||||
reset, or stash verification is claimed.
|
||||
|
||||
## Git and safety state
|
||||
|
||||
- Branch: `main`; HEAD remains `8a632b5e6a3b67c9acd2787e1a686ac17ae69486`.
|
||||
- Existing broad user WIP remains uncommitted and unstaged.
|
||||
- No secrets were read or stored. No reset, stash, cleanup, stage, commit, push,
|
||||
deploy, credential, permission, or production operation was performed.
|
||||
@@ -43,12 +43,15 @@ export const api = {
|
||||
adminCreateUser: (b) => request('POST', '/api/admin/users', b),
|
||||
adminListUsers: () => request('GET', '/api/admin/users'),
|
||||
adminUpdateUser: (username, b) => request('PUT', `/api/admin/users/${username}`, b),
|
||||
adminCreateDemoAccount: (b) => request('POST', '/api/admin/demo-accounts', b),
|
||||
adminRevokeDemoAccount: (username) => request('POST', `/api/admin/demo-accounts/${encodeURIComponent(username)}/revoke`),
|
||||
createGroup: (formData) => request('POST', '/api/groups', formData, true),
|
||||
listGroups: () => request('GET', '/api/groups'),
|
||||
getGroup: (id) => request('GET', `/api/groups/${id}`),
|
||||
updateGroupVisibility: (id, visibility) => request('PATCH', `/api/groups/${id}/visibility`, { visibility }),
|
||||
groupReport: (id) => request('GET', `/api/groups/${id}/report?format=json`),
|
||||
deleteGroup: (id) => request('DELETE', `/api/groups/${id}`),
|
||||
analyzeGroup: (id, opts = {}) => request('POST', `/api/groups/${id}/analyze${opts.append ? '?append=true' : ''}`),
|
||||
analyzeGroup: (id) => request('POST', `/api/groups/${id}/analyze`),
|
||||
listPersonas: (gid) => request('GET', `/api/groups/${gid}/personas`),
|
||||
getPersona: (gid, pid) => request('GET', `/api/groups/${gid}/personas/${pid}`),
|
||||
createPersonaVariant: (gid, pid) => request('POST', `/api/groups/${gid}/personas/${pid}/variant`),
|
||||
|
||||
24
frontend/src/api/index.spec.js
Normal file
24
frontend/src/api/index.spec.js
Normal file
@@ -0,0 +1,24 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
import { api, setToken } from './index'
|
||||
|
||||
describe('API group analysis contract', () => {
|
||||
beforeEach(() => {
|
||||
setToken(null)
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
it('never emits the removed append query parameter', async () => {
|
||||
const fetchMock = vi.spyOn(globalThis, 'fetch').mockResolvedValue({
|
||||
ok: true,
|
||||
json: async () => ({ group: { id: 'g-1' } }),
|
||||
})
|
||||
|
||||
await api.analyzeGroup('g-1', { append: true })
|
||||
|
||||
expect(fetchMock).toHaveBeenCalledWith('/api/groups/g-1/analyze', expect.objectContaining({
|
||||
method: 'POST',
|
||||
}))
|
||||
})
|
||||
})
|
||||
@@ -59,6 +59,7 @@ const messages = {
|
||||
downloadReport: 'Download report',
|
||||
reportLoadFailed: 'Could not load the report',
|
||||
requestFailed: 'Something went wrong. Please try again.',
|
||||
notAvailable: 'not available',
|
||||
setupFailed: 'Could not finish account setup',
|
||||
chatActionFailed: 'Could not complete that chat action',
|
||||
groupCreateFailed: 'Could not create the persona group',
|
||||
@@ -67,6 +68,7 @@ const messages = {
|
||||
noPracticeHint: 'Go to Training and try closing a sale with a persona.',
|
||||
weakAreas: 'Weak areas',
|
||||
weakAreasIntro: 'Choose a weakness to lock into a private persona for repeat practice.',
|
||||
demoWeakAreasReadOnly: 'Read-only analysis — private persona creation is unavailable for demo accounts.',
|
||||
winsLabel: 'Wins',
|
||||
lossesLabel: 'Losses',
|
||||
evidenceFrom: 'Evidence from',
|
||||
@@ -84,6 +86,14 @@ const messages = {
|
||||
privatePersonasHint: 'Create one above, or return to weak areas to choose a challenge.',
|
||||
creating: 'Creating…',
|
||||
productLabel: 'Product',
|
||||
visibility: 'Group visibility',
|
||||
visibilityHint: 'Choose who can use this ready group. Demo groups are available only to demo accounts.',
|
||||
visibilityPublic: 'Public — regular trainees and admins',
|
||||
visibilityHidden: 'Hidden — admins only until published',
|
||||
visibilityDemo: 'Demo — demo accounts only',
|
||||
saveVisibility: 'Save visibility',
|
||||
visibilitySaved: 'Visibility saved.',
|
||||
visibilityUpdateFailed: 'Could not update group visibility. Please try again.',
|
||||
groupInstructions: 'How to use this page',
|
||||
groupInstructionAnalyze: 'The system creates 15 personas automatically after analysis.',
|
||||
groupInstructionEdit: 'Edit a persona card when you need to adjust its details.',
|
||||
@@ -153,14 +163,32 @@ const messages = {
|
||||
noProductsHint: 'Create your first product to start practicing.',
|
||||
groups: 'Customer Groups (Persona)',
|
||||
ownProducts: 'My products',
|
||||
noOwnProducts: 'No products yet',
|
||||
noOwnProductsHint: 'Add a product first. Its personas will be generated and shown inside that product.',
|
||||
orgProducts: 'Org products',
|
||||
myTraining: 'My Training',
|
||||
adminTools: 'Admin Tools',
|
||||
users: 'Users',
|
||||
createDemoAccount: 'Create demo account',
|
||||
demoAccountIntro: 'Create a dedicated demo login for a time-limited trial. The temporary password is returned once only.',
|
||||
demoEmailPlaceholder: 'demo recipient email',
|
||||
demoAccountOneTime: 'One-time demo credentials',
|
||||
demoAccountWarning: 'Copy or deliver these credentials now. The password cannot be retrieved later.',
|
||||
copyCredentials: 'Copy credentials',
|
||||
copied: 'Copied',
|
||||
copyFailed: 'Could not copy. Copy the credentials manually.',
|
||||
deliveryStatuses: 'Delivery status',
|
||||
deliveryEmail: 'Email',
|
||||
deliveryWebhook: 'Webhook',
|
||||
revokeDemoAccount: 'Revoke demo',
|
||||
revokeDemoConfirm: 'Revoke this demo account now? Its active sessions will stop working.',
|
||||
revokeDemoFailed: 'Could not revoke the demo account.',
|
||||
demoAccountCreateFailed: 'Could not create the demo account.',
|
||||
analytics: 'Analytics',
|
||||
groupBuilder: 'Create persona group',
|
||||
create: 'Create',
|
||||
analyze: 'Analyze',
|
||||
analyzing: 'Analyzing…',
|
||||
manual: 'Manual',
|
||||
edit: 'Edit',
|
||||
product: 'Product',
|
||||
@@ -201,6 +229,14 @@ const messages = {
|
||||
openSaleTask: 'The customer did NOT message first. You must open the sale.',
|
||||
sellerInitiated: 'You must open the sale (outbound)',
|
||||
customerInitiated: 'The customer will message you first',
|
||||
demoTrialFirstLoginTitle: 'Demo trial not started',
|
||||
demoTrialFirstLoginText: 'The 30-day demo trial starts after the first successful login.',
|
||||
demoTrialActiveTitle: 'Demo trial active',
|
||||
demoTrialActiveText: 'You can practice only with ready groups marked for demo use.',
|
||||
demoTrialExpiresAt: 'Expires',
|
||||
demoTrialExpiredTitle: 'Demo trial expired',
|
||||
demoTrialExpiredText: 'This demo trial has expired. Ask a super admin for a new demo account.',
|
||||
demoNoTraining: 'No ready demo groups are available yet. Ask a super admin to publish one.',
|
||||
},
|
||||
th: {
|
||||
app: 'ระบบฝึกทักษะการขาย',
|
||||
@@ -259,6 +295,7 @@ const messages = {
|
||||
downloadReport: 'ดาวน์โหลดรายงาน',
|
||||
reportLoadFailed: 'โหลดรายงานไม่สำเร็จ',
|
||||
requestFailed: 'เกิดข้อผิดพลาด กรุณาลองใหม่อีกครั้ง',
|
||||
notAvailable: 'ไม่มีข้อมูล',
|
||||
setupFailed: 'ตั้งค่าบัญชีไม่สำเร็จ',
|
||||
chatActionFailed: 'ดำเนินการในแชทไม่สำเร็จ',
|
||||
groupCreateFailed: 'สร้างกลุ่ม persona ไม่สำเร็จ',
|
||||
@@ -267,6 +304,7 @@ const messages = {
|
||||
noPracticeHint: 'ไปที่หน้าการฝึก แล้วลองปิดการขายกับ persona สักคน',
|
||||
weakAreas: 'จุดอ่อนที่ควรฝึก',
|
||||
weakAreasIntro: 'เลือกจุดที่อยากล็อกไว้ แล้วสร้าง persona ส่วนตัวสำหรับฝึกซ้ำ',
|
||||
demoWeakAreasReadOnly: 'อ่านผลวิเคราะห์ได้อย่างเดียว — บัญชีทดลองไม่สามารถสร้าง persona ส่วนตัวได้',
|
||||
winsLabel: 'ชนะ',
|
||||
lossesLabel: 'แพ้',
|
||||
evidenceFrom: 'หลักฐานจาก',
|
||||
@@ -284,6 +322,14 @@ const messages = {
|
||||
privatePersonasHint: 'สร้างจากโจทย์ด้านบน หรือกลับไปที่จุดอ่อนเพื่อเลือกโจทย์',
|
||||
creating: 'กำลังสร้าง…',
|
||||
productLabel: 'สินค้า',
|
||||
visibility: 'การมองเห็นของกลุ่ม',
|
||||
visibilityHint: 'เลือกผู้ที่ใช้กลุ่มที่พร้อมฝึกนี้ได้ กลุ่ม Demo จะแสดงเฉพาะบัญชีทดลอง',
|
||||
visibilityPublic: 'สาธารณะ — ผู้ฝึกและผู้ดูแลทั่วไป',
|
||||
visibilityHidden: 'ซ่อน — ผู้ดูแลเห็นจนกว่าจะเผยแพร่',
|
||||
visibilityDemo: 'Demo — เฉพาะบัญชีทดลอง',
|
||||
saveVisibility: 'บันทึกการมองเห็น',
|
||||
visibilitySaved: 'บันทึกการมองเห็นแล้ว',
|
||||
visibilityUpdateFailed: 'เปลี่ยนการมองเห็นของกลุ่มไม่สำเร็จ กรุณาลองใหม่',
|
||||
groupInstructions: 'วิธีใช้หน้านี้',
|
||||
groupInstructionAnalyze: 'ระบบจะสร้าง persona 15 คนให้อัตโนมัติเมื่อวิเคราะห์เสร็จ',
|
||||
groupInstructionEdit: 'กดแก้ไขการ์ดเพื่อปรับรายละเอียด persona ให้ตรงกับโจทย์',
|
||||
@@ -353,14 +399,32 @@ const messages = {
|
||||
noProductsHint: 'สร้างสินค้าชิ้นแรกของคุณเพื่อเริ่มฝึก',
|
||||
groups: 'กลุ่มลูกค้า (Persona)',
|
||||
ownProducts: 'สินค้าของฉัน',
|
||||
noOwnProducts: 'ยังไม่มีสินค้าของคุณ',
|
||||
noOwnProductsHint: 'เพิ่มสินค้าก่อน ระบบจะสร้าง persona และแสดง persona ไว้ภายในสินค้านั้น',
|
||||
orgProducts: 'สินค้าขององค์กร',
|
||||
myTraining: 'ประวัติการฝึก',
|
||||
adminTools: 'เครื่องมือผู้ดูแลระบบ',
|
||||
users: 'ผู้ใช้งาน',
|
||||
createDemoAccount: 'สร้างบัญชีทดลอง',
|
||||
demoAccountIntro: 'สร้างบัญชีเข้าสู่ระบบสำหรับทดลองใช้งานแบบมีเวลาจำกัด รหัสผ่านชั่วคราวจะแสดงให้เห็นเพียงครั้งเดียว',
|
||||
demoEmailPlaceholder: 'อีเมลผู้รับบัญชีทดลอง',
|
||||
demoAccountOneTime: 'ข้อมูลเข้าสู่ระบบบัญชีทดลอง (ครั้งเดียว)',
|
||||
demoAccountWarning: 'คัดลอกหรือส่งข้อมูลนี้ทันที ระบบจะไม่สามารถเรียกดูรหัสผ่านภายหลังได้',
|
||||
copyCredentials: 'คัดลอกข้อมูลเข้าสู่ระบบ',
|
||||
copied: 'คัดลอกแล้ว',
|
||||
copyFailed: 'คัดลอกไม่สำเร็จ กรุณาคัดลอกข้อมูลด้วยตนเอง',
|
||||
deliveryStatuses: 'สถานะการส่งข้อมูล',
|
||||
deliveryEmail: 'อีเมล',
|
||||
deliveryWebhook: 'Webhook',
|
||||
revokeDemoAccount: 'เพิกถอน Demo',
|
||||
revokeDemoConfirm: 'ต้องการเพิกถอนบัญชีทดลองนี้หรือไม่? session ที่ใช้งานอยู่จะหยุดทำงาน',
|
||||
revokeDemoFailed: 'เพิกถอนบัญชีทดลองไม่สำเร็จ',
|
||||
demoAccountCreateFailed: 'สร้างบัญชีทดลองไม่สำเร็จ',
|
||||
analytics: 'สถิติ',
|
||||
groupBuilder: 'สร้างกลุ่ม persona',
|
||||
create: 'สร้าง',
|
||||
analyze: 'วิเคราะห์',
|
||||
analyzing: 'กำลังวิเคราะห์…',
|
||||
manual: 'ระบุเอง',
|
||||
edit: 'แก้ไข',
|
||||
product: 'สินค้า/บริการ/ไอเดีย',
|
||||
@@ -401,6 +465,14 @@ const messages = {
|
||||
openSaleTask: 'ลูกค้ายังไม่ได้ทักเข้ามา คุณต้องเป็นฝ่ายเริ่มบทสนทนาการขายเอง',
|
||||
sellerInitiated: 'คุณต้องเริ่มการขายในเชิงรุก',
|
||||
customerInitiated: 'ลูกค้าจะติดต่อเข้ามาก่อน',
|
||||
demoTrialFirstLoginTitle: 'ยังไม่เริ่มช่วงทดลอง',
|
||||
demoTrialFirstLoginText: 'ช่วงทดลอง 30 วันจะเริ่มหลังจากเข้าสู่ระบบสำเร็จครั้งแรก',
|
||||
demoTrialActiveTitle: 'ช่วงทดลองกำลังใช้งาน',
|
||||
demoTrialActiveText: 'คุณฝึกได้เฉพาะกลุ่มที่พร้อมและถูกกำหนดให้ใช้กับ Demo เท่านั้น',
|
||||
demoTrialExpiresAt: 'หมดอายุ',
|
||||
demoTrialExpiredTitle: 'ช่วงทดลองหมดอายุแล้ว',
|
||||
demoTrialExpiredText: 'ช่วงทดลองนี้หมดอายุแล้ว กรุณาติดต่อ super admin เพื่อสร้างบัญชีทดลองใหม่',
|
||||
demoNoTraining: 'ยังไม่มีกลุ่ม Demo ที่พร้อมฝึก กรุณาให้ super admin เผยแพร่กลุ่มก่อน',
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -4,10 +4,17 @@ const authMock = vi.hoisted(() => ({
|
||||
user: null,
|
||||
mustSetup: false,
|
||||
isAdmin: false,
|
||||
isSuperAdmin: false,
|
||||
isDemo: false,
|
||||
load: vi.fn(),
|
||||
}))
|
||||
const apiMock = vi.hoisted(() => ({
|
||||
getGroup: vi.fn(),
|
||||
getSession: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('./store/auth', () => ({ auth: authMock }))
|
||||
vi.mock('./api', () => ({ api: apiMock }))
|
||||
|
||||
const { default: router } = await import('./router')
|
||||
|
||||
@@ -16,7 +23,11 @@ describe('router access guards', () => {
|
||||
authMock.user = null
|
||||
authMock.mustSetup = false
|
||||
authMock.isAdmin = false
|
||||
authMock.isSuperAdmin = false
|
||||
authMock.isDemo = false
|
||||
authMock.load.mockReset()
|
||||
apiMock.getGroup.mockReset()
|
||||
apiMock.getSession.mockReset()
|
||||
await router.push('/login')
|
||||
})
|
||||
|
||||
@@ -53,4 +64,85 @@ describe('router access guards', () => {
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/training')
|
||||
})
|
||||
|
||||
it('allows regular users to open product creation from training', async () => {
|
||||
authMock.user = { id: 'trainee', role: 'user' }
|
||||
authMock.isAdmin = false
|
||||
|
||||
await router.push('/products/new')
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/products/new')
|
||||
})
|
||||
|
||||
it('keeps demo users on training and out of admin/product creation pages', async () => {
|
||||
authMock.user = { id: 'demo-user', role: 'demo', is_demo: true }
|
||||
authMock.isAdmin = false
|
||||
authMock.isDemo = true
|
||||
|
||||
await router.push('/admin/users')
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/training')
|
||||
|
||||
await router.push('/')
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/training')
|
||||
|
||||
await router.push('/products/new')
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/training')
|
||||
})
|
||||
|
||||
it('redirects deep links to personas when the group is not ready', async () => {
|
||||
authMock.user = { id: 'trainee', role: 'user' }
|
||||
apiMock.getGroup.mockResolvedValueOnce({
|
||||
group: { id: 'draft-group', status: 'draft' },
|
||||
})
|
||||
|
||||
await router.push('/groups/draft-group/personas')
|
||||
|
||||
expect(apiMock.getGroup).toHaveBeenCalledWith('draft-group')
|
||||
expect(router.currentRoute.value.path).toBe('/training')
|
||||
})
|
||||
|
||||
it('blocks a demo user from a ready non-demo deep link', async () => {
|
||||
authMock.user = { id: 'demo-user', role: 'demo', is_demo: true }
|
||||
authMock.isDemo = true
|
||||
apiMock.getGroup.mockResolvedValueOnce({
|
||||
group: { id: 'public-group', status: 'ready', visibility: 'public', is_owned: false },
|
||||
})
|
||||
|
||||
await router.push('/groups/public-group/personas')
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/training')
|
||||
})
|
||||
|
||||
it('blocks a regular user from a ready private group they do not own', async () => {
|
||||
authMock.user = { id: 'trainee', role: 'user' }
|
||||
apiMock.getGroup.mockResolvedValueOnce({
|
||||
group: { id: 'private-group', status: 'ready', visibility: 'private', is_owned: false },
|
||||
})
|
||||
|
||||
await router.push('/groups/private-group/personas')
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/training')
|
||||
})
|
||||
|
||||
it.each(['admin', 'super_admin'])(
|
||||
'allows a promoted %s owner into a ready private product',
|
||||
async (role) => {
|
||||
authMock.user = { id: 'promoted-owner', role }
|
||||
authMock.isAdmin = true
|
||||
authMock.isSuperAdmin = role === 'super_admin'
|
||||
apiMock.getGroup.mockResolvedValueOnce({
|
||||
group: {
|
||||
id: 'owned-private-group', status: 'ready',
|
||||
visibility: 'private', is_owned: true,
|
||||
},
|
||||
})
|
||||
|
||||
await router.push('/groups/owned-private-group/personas')
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/groups/owned-private-group/personas')
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
@@ -1,6 +1,40 @@
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { api } from '../api'
|
||||
import { auth } from '../store/auth'
|
||||
|
||||
async function requireReadyGroup(to) {
|
||||
try {
|
||||
const response = await api.getGroup(to.params.gid)
|
||||
const group = response?.group
|
||||
if (group?.status !== 'ready') return { path: '/training' }
|
||||
if (auth.isSuperAdmin) return true
|
||||
if (auth.isDemo) {
|
||||
return group.visibility === 'demo' && group.is_owned !== true
|
||||
? true
|
||||
: { path: '/training' }
|
||||
}
|
||||
if (group.is_owned === true) return true
|
||||
if (auth.isAdmin) {
|
||||
return ['public', 'hidden'].includes(group.visibility)
|
||||
? true
|
||||
: { path: '/training' }
|
||||
}
|
||||
return group.visibility === 'public' ? true : { path: '/training' }
|
||||
} catch (_error) {
|
||||
// Treat stale/foreign/malformed group links as unavailable.
|
||||
}
|
||||
return { path: '/training' }
|
||||
}
|
||||
|
||||
async function requireOwnSession(to) {
|
||||
try {
|
||||
await api.getSession(to.params.sid)
|
||||
return true
|
||||
} catch (_error) {
|
||||
return { path: '/training' }
|
||||
}
|
||||
}
|
||||
|
||||
const routes = [
|
||||
{ path: '/login', component: () => import('../views/Login.vue'), meta: { public: true } },
|
||||
{ path: '/setup', component: () => import('../views/Setup.vue') },
|
||||
@@ -10,14 +44,15 @@ const routes = [
|
||||
{ path: '/my/board', component: () => import('../views/MyBoard.vue') },
|
||||
// Tab 3: Admin overview (aggregate stats + date filter, admin only)
|
||||
{ path: '/', component: () => import('../views/Analytics.vue'), meta: { admin: true } },
|
||||
{ path: '/sessions/:sid', component: () => import('../views/SessionDetail.vue') },
|
||||
{ path: '/groups/:gid/personas', component: () => import('../views/Personas.vue') },
|
||||
{ path: '/groups/:gid/chat/:pid', component: () => import('../views/Chat.vue') },
|
||||
{ path: '/sessions/:sid', component: () => import('../views/SessionDetail.vue'), beforeEnter: requireOwnSession },
|
||||
{ path: '/groups/:gid/personas', component: () => import('../views/Personas.vue'), beforeEnter: requireReadyGroup },
|
||||
{ path: '/groups/:gid/chat/:pid', component: () => import('../views/Chat.vue'), beforeEnter: requireReadyGroup },
|
||||
// Settings
|
||||
{ path: '/settings', component: () => import('../views/Settings.vue') },
|
||||
// Guide (how to use, non-IT friendly) — reached from the topbar guide entry.
|
||||
{ path: '/guide', component: () => import('../views/Guide.vue') },
|
||||
// Admin management
|
||||
// Product creation — regular users create private products; admins use the admin route.
|
||||
{ path: '/products/new', component: () => import('../views/GroupBuilder.vue'), meta: { nonDemo: true } },
|
||||
{ path: '/admin/new-group', component: () => import('../views/GroupBuilder.vue'), meta: { admin: true } },
|
||||
{ path: '/admin/groups/:gid/edit', component: () => import('../views/GroupEdit.vue'), meta: { admin: true } },
|
||||
{ path: '/admin/users', component: () => import('../views/AdminUsers.vue'), meta: { admin: true } },
|
||||
@@ -48,6 +83,9 @@ router.beforeEach(async (to) => {
|
||||
if (to.meta.admin && !auth.isAdmin) {
|
||||
return { path: '/training' }
|
||||
}
|
||||
if (to.meta.nonDemo && auth.isDemo) {
|
||||
return { path: '/training' }
|
||||
}
|
||||
return true
|
||||
})
|
||||
|
||||
|
||||
@@ -12,6 +12,9 @@ export const auth = reactive({
|
||||
get isAdmin() {
|
||||
return this.role === 'admin' || this.role === 'super_admin'
|
||||
},
|
||||
get isDemo() {
|
||||
return this.role === 'demo' || this.user?.is_demo === true
|
||||
},
|
||||
get isSuperAdmin() {
|
||||
return this.role === 'super_admin'
|
||||
},
|
||||
@@ -20,7 +23,7 @@ export const auth = reactive({
|
||||
try {
|
||||
const data = await api.me()
|
||||
this.user = data.user
|
||||
this.mustSetup = !!data.user?.must_setup
|
||||
this.mustSetup = data.user?.must_setup !== false || data.user?.accepted_terms !== true
|
||||
return this.user
|
||||
} catch (e) {
|
||||
this.user = null
|
||||
@@ -34,7 +37,7 @@ export const auth = reactive({
|
||||
this.token = data.token
|
||||
setToken(data.token)
|
||||
this.user = data.user
|
||||
this.mustSetup = !!data.must_setup
|
||||
this.mustSetup = data.must_setup !== false || data.user?.accepted_terms !== true
|
||||
return data.user
|
||||
},
|
||||
async loginOAuth(provider, token) {
|
||||
@@ -42,7 +45,7 @@ export const auth = reactive({
|
||||
this.token = data.token
|
||||
setToken(data.token)
|
||||
this.user = data.user
|
||||
this.mustSetup = !!data.must_setup
|
||||
this.mustSetup = data.must_setup !== false || data.user?.accepted_terms !== true
|
||||
return data.user
|
||||
},
|
||||
async finishSetup(email, password, acceptedTerms = false) {
|
||||
@@ -50,7 +53,7 @@ export const auth = reactive({
|
||||
this.token = data.token
|
||||
setToken(data.token)
|
||||
this.user = data.user
|
||||
this.mustSetup = false
|
||||
this.mustSetup = data.must_setup !== false || data.user?.must_setup !== false || data.user?.accepted_terms !== true
|
||||
return data.user
|
||||
},
|
||||
logout() {
|
||||
|
||||
45
frontend/src/store/auth.spec.js
Normal file
45
frontend/src/store/auth.spec.js
Normal file
@@ -0,0 +1,45 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const apiMock = vi.hoisted(() => ({
|
||||
setup: vi.fn(),
|
||||
}))
|
||||
|
||||
vi.mock('../api', () => ({
|
||||
api: apiMock,
|
||||
getToken: () => null,
|
||||
setToken: vi.fn(),
|
||||
}))
|
||||
|
||||
const { auth } = await import('./auth')
|
||||
|
||||
describe('auth setup state', () => {
|
||||
beforeEach(() => {
|
||||
auth.logout()
|
||||
auth.user = { username: 'trainee' }
|
||||
auth.mustSetup = true
|
||||
apiMock.setup.mockReset()
|
||||
})
|
||||
|
||||
it('keeps setup required when the response markers are incomplete', async () => {
|
||||
apiMock.setup.mockResolvedValue({
|
||||
token: 'token',
|
||||
user: { must_setup: false, accepted_terms: false },
|
||||
})
|
||||
|
||||
await auth.finishSetup('trainee@example.com', 'long-enough-password', true)
|
||||
|
||||
expect(auth.mustSetup).toBe(true)
|
||||
})
|
||||
|
||||
it('clears setup only when both returned markers are complete', async () => {
|
||||
apiMock.setup.mockResolvedValue({
|
||||
token: 'token',
|
||||
must_setup: false,
|
||||
user: { must_setup: false, accepted_terms: true },
|
||||
})
|
||||
|
||||
await auth.finishSetup('trainee@example.com', 'long-enough-password', true)
|
||||
|
||||
expect(auth.mustSetup).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -11,6 +11,50 @@
|
||||
</ol>
|
||||
</div>
|
||||
|
||||
<div v-if="auth.isSuperAdmin" class="card demo-account-card" data-testid="demo-account-form">
|
||||
<h4 style="margin-top:0"><UserPlus :size="18" :stroke-width="1.8" style="vertical-align:-3px" /> {{ i18n.t('createDemoAccount') }}</h4>
|
||||
<p class="muted demo-account-intro">{{ i18n.t('demoAccountIntro') }}</p>
|
||||
<label for="demo-account-email">{{ i18n.t('email') }}</label>
|
||||
<input
|
||||
id="demo-account-email"
|
||||
data-testid="demo-account-email"
|
||||
v-model="demoEmail"
|
||||
type="email"
|
||||
autocomplete="email"
|
||||
:placeholder="i18n.t('demoEmailPlaceholder')"
|
||||
@keyup.enter="createDemoAccount"
|
||||
/>
|
||||
<button
|
||||
data-testid="create-demo-account"
|
||||
class="primary"
|
||||
style="margin-top:10px"
|
||||
:disabled="demoCreating || !demoEmail.trim()"
|
||||
@click="createDemoAccount"
|
||||
>
|
||||
{{ demoCreating ? i18n.t('creating') : i18n.t('createDemoAccount') }}
|
||||
</button>
|
||||
<div v-if="demoError" class="error" role="alert">{{ demoError }}</div>
|
||||
|
||||
<div v-if="demoCredentials" class="demo-credentials" data-testid="demo-credentials" role="status">
|
||||
<strong>{{ i18n.t('demoAccountOneTime') }}</strong>
|
||||
<p class="warning">{{ i18n.t('demoAccountWarning') }}</p>
|
||||
<dl class="credential-list">
|
||||
<div><dt>{{ i18n.t('username') }}</dt><dd data-testid="demo-username">{{ demoCredentials.username }}</dd></div>
|
||||
<div><dt>{{ i18n.t('password') }}</dt><dd data-testid="demo-password">{{ demoCredentials.temporary_password }}</dd></div>
|
||||
</dl>
|
||||
<button data-testid="copy-demo-credentials" class="soft" @click="copyDemoCredentials">
|
||||
{{ i18n.t('copyCredentials') }}
|
||||
</button>
|
||||
<span v-if="demoCopyStatus" class="muted copy-status" role="status">{{ demoCopyStatus }}</span>
|
||||
|
||||
<div class="delivery-statuses" aria-label="Credential delivery statuses">
|
||||
<strong>{{ i18n.t('deliveryStatuses') }}</strong>
|
||||
<span>{{ i18n.t('deliveryEmail') }}: <b>{{ deliveryStatus('email') }}</b></span>
|
||||
<span>{{ i18n.t('deliveryWebhook') }}: <b>{{ deliveryStatus('webhook') }}</b></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" style="margin-bottom:16px">
|
||||
<h4 style="margin-top:0"><UserPlus :size="18" :stroke-width="1.8" style="vertical-align:-3px" /> + {{ i18n.t('create') }} {{ i18n.t('users').toLowerCase() }}</h4>
|
||||
<div class="row">
|
||||
@@ -35,6 +79,15 @@
|
||||
<strong style="flex:1">{{ u.name }} ({{ u.username }})</strong>
|
||||
<span class="badge">{{ u.role }}</span>
|
||||
<span class="badge" :class="u.active ? 'won' : 'lost'">{{ u.active ? 'active' : 'inactive' }}</span>
|
||||
<button
|
||||
v-if="auth.isSuperAdmin && u.role === 'demo' && u.active"
|
||||
data-testid="revoke-demo-account"
|
||||
class="soft revoke-btn"
|
||||
:aria-label="`${i18n.t('revokeDemoAccount')}: ${u.username}`"
|
||||
@click="revokeDemoAccount(u)"
|
||||
>
|
||||
{{ i18n.t('revokeDemoAccount') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
@@ -43,11 +96,17 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { Users as UsersIcon, UserPlus, Info } from 'lucide-vue-next'
|
||||
import { api } from '../api'
|
||||
import { auth } from '../store/auth'
|
||||
import { i18n } from '../i18n'
|
||||
|
||||
const users = ref([])
|
||||
const error = ref('')
|
||||
const form = ref({ username: '', name: '', password: '', role: 'user' })
|
||||
const demoEmail = ref('')
|
||||
const demoCreating = ref(false)
|
||||
const demoError = ref('')
|
||||
const demoCredentials = ref(null)
|
||||
const demoCopyStatus = ref('')
|
||||
|
||||
async function load() { users.value = (await api.adminListUsers()).users }
|
||||
async function create() {
|
||||
@@ -58,9 +117,65 @@ async function create() {
|
||||
await load()
|
||||
} catch (e) { error.value = i18n.t('requestFailed') }
|
||||
}
|
||||
async function createDemoAccount() {
|
||||
if (!demoEmail.value.trim()) return
|
||||
demoCreating.value = true
|
||||
demoError.value = ''
|
||||
demoCopyStatus.value = ''
|
||||
try {
|
||||
const data = await api.adminCreateDemoAccount({ email: demoEmail.value.trim() })
|
||||
demoCredentials.value = data
|
||||
demoEmail.value = ''
|
||||
await load()
|
||||
} catch (e) {
|
||||
demoError.value = i18n.t('demoAccountCreateFailed')
|
||||
} finally {
|
||||
demoCreating.value = false
|
||||
}
|
||||
}
|
||||
function deliveryStatus(channel) {
|
||||
return demoCredentials.value?.delivery?.[channel]?.status || i18n.t('notAvailable')
|
||||
}
|
||||
async function copyDemoCredentials() {
|
||||
if (!demoCredentials.value) return
|
||||
const text = `${i18n.t('username')}: ${demoCredentials.value.username}\n${i18n.t('password')}: ${demoCredentials.value.temporary_password}`
|
||||
try {
|
||||
if (!navigator.clipboard?.writeText) throw new Error('clipboard unavailable')
|
||||
await navigator.clipboard.writeText(text)
|
||||
demoCopyStatus.value = i18n.t('copied')
|
||||
} catch (e) {
|
||||
demoCopyStatus.value = i18n.t('copyFailed')
|
||||
}
|
||||
}
|
||||
async function revokeDemoAccount(user) {
|
||||
if (!window.confirm(i18n.t('revokeDemoConfirm'))) return
|
||||
demoError.value = ''
|
||||
try {
|
||||
await api.adminRevokeDemoAccount(user.username)
|
||||
await load()
|
||||
} catch (e) {
|
||||
demoError.value = i18n.t('revokeDemoFailed')
|
||||
}
|
||||
}
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.guide { background: #eef2ff; border-color: #c7d2fe; margin-bottom: 16px; }
|
||||
.demo-account-card { background: #f8fafc; border-color: #cbd5e1; margin-bottom: 16px; }
|
||||
.demo-account-intro { margin: 0 0 12px; }
|
||||
.demo-credentials { margin-top: 16px; padding: 14px; background: #fffbeb; border: 1px solid #fde68a; border-radius: 10px; display: flex; flex-direction: column; gap: 8px; }
|
||||
.warning { margin: 0; color: #92400e; font-weight: 650; }
|
||||
.credential-list { margin: 0; display: grid; gap: 6px; }
|
||||
.credential-list > div { display: grid; grid-template-columns: 110px minmax(0, 1fr); gap: 10px; align-items: baseline; }
|
||||
.credential-list dt { color: var(--muted); font-size: 13px; }
|
||||
.credential-list dd { margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; overflow-wrap: anywhere; }
|
||||
.copy-status { font-size: 13px; }
|
||||
.delivery-statuses { display: flex; flex-wrap: wrap; gap: 8px 14px; padding-top: 8px; border-top: 1px solid #fde68a; font-size: 13px; }
|
||||
.delivery-statuses strong { width: 100%; }
|
||||
.revoke-btn { margin-left: auto; white-space: nowrap; }
|
||||
@media (max-width: 600px) {
|
||||
.credential-list > div { grid-template-columns: 1fr; gap: 2px; }
|
||||
.revoke-btn { margin-left: 0; }
|
||||
}
|
||||
</style>
|
||||
|
||||
113
frontend/src/views/Chat.spec.js
Normal file
113
frontend/src/views/Chat.spec.js
Normal file
@@ -0,0 +1,113 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
api: {
|
||||
getPersona: vi.fn(),
|
||||
getGroup: vi.fn(),
|
||||
chatResume: vi.fn(),
|
||||
chatStart: vi.fn(),
|
||||
mySessions: vi.fn(),
|
||||
},
|
||||
route: { params: { gid: 'group-1', pid: 'persona-1' } },
|
||||
auth: { isAdmin: true },
|
||||
}))
|
||||
|
||||
vi.mock('../api', () => ({ api: mocks.api }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => mocks.route }))
|
||||
vi.mock('../store/auth', () => ({ auth: mocks.auth }))
|
||||
|
||||
const RouterLinkStub = {
|
||||
props: ['to'],
|
||||
template: '<a :href="to"><slot /></a>',
|
||||
}
|
||||
|
||||
describe('Chat resume state', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.stubGlobal('alert', vi.fn())
|
||||
mocks.auth.isAdmin = true
|
||||
mocks.api.getPersona.mockResolvedValue({
|
||||
persona: {
|
||||
id: 'persona-1',
|
||||
name: 'Persona',
|
||||
profession: 'Operator',
|
||||
age_group: '35-44',
|
||||
},
|
||||
})
|
||||
mocks.api.getGroup.mockResolvedValue({ group: { visibility: 'hidden' } })
|
||||
mocks.api.chatResume.mockResolvedValue({
|
||||
session: { id: 'session-1', messages: [], debrief: null },
|
||||
scenario: 'social',
|
||||
})
|
||||
})
|
||||
|
||||
it('opens the composer for an active session with an empty transcript', async () => {
|
||||
const { default: Chat } = await import('./Chat.vue')
|
||||
const wrapper = mount(Chat, {
|
||||
global: { stubs: { 'router-link': RouterLinkStub } },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('.composer').exists()).toBe(true)
|
||||
expect(wrapper.find('input[placeholder]').exists()).toBe(true)
|
||||
expect(wrapper.text()).not.toContain('เลือกสถานการณ์')
|
||||
})
|
||||
|
||||
it('starts a hidden-group admin chat in preview mode', async () => {
|
||||
mocks.api.chatResume.mockRejectedValue(new Error('no active session'))
|
||||
mocks.api.mySessions.mockResolvedValue({ sessions: [] })
|
||||
mocks.api.chatStart.mockResolvedValue({
|
||||
session: { id: 'preview-session', messages: [] },
|
||||
})
|
||||
const { default: Chat } = await import('./Chat.vue')
|
||||
const wrapper = mount(Chat, {
|
||||
global: { stubs: { 'router-link': RouterLinkStub } },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('button.primary').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.api.chatStart).toHaveBeenCalledWith(
|
||||
'group-1', 'persona-1', 'social', expect.any(String), 'preview'
|
||||
)
|
||||
})
|
||||
|
||||
it('does not fall back to a trainee chat when admin group verification fails', async () => {
|
||||
mocks.api.getGroup.mockRejectedValue(new Error('group unavailable'))
|
||||
mocks.api.chatResume.mockRejectedValue(new Error('no active session'))
|
||||
mocks.api.mySessions.mockResolvedValue({ sessions: [] })
|
||||
mocks.api.chatStart.mockResolvedValue({ session: { id: 'unexpected', messages: [] } })
|
||||
const { default: Chat } = await import('./Chat.vue')
|
||||
const wrapper = mount(Chat, {
|
||||
global: { stubs: { 'router-link': RouterLinkStub } },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
await wrapper.find('button.primary').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.api.chatStart).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('links a regular user to their personal board after practice is finished', async () => {
|
||||
mocks.auth.isAdmin = false
|
||||
mocks.api.chatResume.mockResolvedValueOnce({
|
||||
session: {
|
||||
id: 'finished-session',
|
||||
messages: [],
|
||||
debrief: { outcome: 'won', score: 80 },
|
||||
},
|
||||
scenario: 'social',
|
||||
})
|
||||
const { default: Chat } = await import('./Chat.vue')
|
||||
const wrapper = mount(Chat, {
|
||||
global: { stubs: { 'router-link': RouterLinkStub } },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('a[href="/my/board"]').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -72,6 +72,9 @@
|
||||
<div v-if="phase === 'chat'" class="composer">
|
||||
<input v-model="text" @keyup.enter="send" :disabled="sending" :placeholder="i18n.t('send')" />
|
||||
<button class="primary" @click="send" :disabled="sending || !text.trim()"><Send :size="16" :stroke-width="1.8" /> {{ i18n.t('send') }}</button>
|
||||
<button class="ghost" @click="finish" :disabled="sending">
|
||||
{{ i18n.locale === 'en' ? 'Finish practice' : 'จบการฝึก' }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Debrief -->
|
||||
@@ -99,7 +102,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
<router-link to="/"><button class="primary" style="width:100%;margin-top:14px">{{ i18n.t('dashboard') }}</button></router-link>
|
||||
<router-link :to="auth.isAdmin ? '/' : '/my/board'"><button class="primary" style="width:100%;margin-top:14px">{{ i18n.t('dashboard') }}</button></router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -111,6 +114,7 @@ import { useRoute } from 'vue-router'
|
||||
import { Target, ArrowLeft, Search, MessageCircle, Phone, Play, Send, UserRound, Sparkles } from 'lucide-vue-next'
|
||||
import { api } from '../api'
|
||||
import { i18n } from '../i18n'
|
||||
import { auth } from '../store/auth'
|
||||
|
||||
const route = useRoute()
|
||||
const gid = route.params.gid
|
||||
@@ -124,6 +128,7 @@ const sending = ref(false)
|
||||
const debrief = ref(null)
|
||||
const sessionId = ref(null)
|
||||
const picked = ref('social')
|
||||
const group = ref(null)
|
||||
|
||||
const scenarios = computed(() => {
|
||||
const en = i18n.locale === 'en'
|
||||
@@ -142,7 +147,11 @@ const thread = ref(null)
|
||||
|
||||
async function begin() {
|
||||
try {
|
||||
const res = await api.chatStart(gid, pid, picked.value, i18n.locale, 'trainee')
|
||||
if (auth.isAdmin && (!group.value || !['public', 'hidden'].includes(group.value.visibility))) {
|
||||
throw new Error('admin group verification failed')
|
||||
}
|
||||
const mode = auth.isAdmin && group.value.visibility === 'hidden' ? 'preview' : 'trainee'
|
||||
const res = await api.chatStart(gid, pid, picked.value, i18n.locale, mode)
|
||||
phase.value = 'chat'
|
||||
sessionId.value = res.session.id
|
||||
messages.value = res.session.messages || []
|
||||
@@ -152,14 +161,37 @@ async function begin() {
|
||||
}
|
||||
}
|
||||
|
||||
async function finish() {
|
||||
if (sending.value) return
|
||||
sending.value = true
|
||||
try {
|
||||
const res = await api.chatFinish(gid, pid)
|
||||
debrief.value = res.debrief || null
|
||||
messages.value = res.session?.messages || messages.value
|
||||
phase.value = 'done'
|
||||
scrollDown()
|
||||
} catch (e) {
|
||||
alert(i18n.t('chatActionFailed'))
|
||||
} finally {
|
||||
sending.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
persona.value = (await api.getPersona(gid, pid)).persona
|
||||
if (auth.isAdmin) {
|
||||
try {
|
||||
group.value = (await api.getGroup(gid)).group
|
||||
} catch (_error) {
|
||||
group.value = null
|
||||
}
|
||||
}
|
||||
// Resume: if there's an unfinished session for this persona, continue it instead of restarting.
|
||||
try {
|
||||
const r = await api.chatResume(gid, pid)
|
||||
sessionId.value = r.session.id
|
||||
messages.value = r.session.messages || []
|
||||
if (messages.value.length && !r.session.debrief) {
|
||||
if (!r.session.debrief) {
|
||||
phase.value = 'chat'
|
||||
// re-derive which scenario was active
|
||||
const sc = scenarios.value.find((s) => s.id === r.scenario)
|
||||
|
||||
@@ -61,12 +61,25 @@
|
||||
<option value="en">{{ i18n.t('english') }}</option>
|
||||
</select>
|
||||
|
||||
<template v-if="auth.isAdmin">
|
||||
<label for="group-visibility">{{ i18n.t('visibility') }}</label>
|
||||
<div id="group-visibility-hint" class="muted label-hint">{{ i18n.t('visibilityHint') }}</div>
|
||||
<select id="group-visibility" data-testid="group-visibility" v-model="form.visibility" aria-describedby="group-visibility-hint">
|
||||
<option value="public">{{ i18n.t('visibilityPublic') }}</option>
|
||||
<option value="hidden">{{ i18n.t('visibilityHidden') }}</option>
|
||||
<option v-if="auth.isSuperAdmin" value="demo">{{ i18n.t('visibilityDemo') }}</option>
|
||||
</select>
|
||||
</template>
|
||||
|
||||
<label><Paperclip :size="15" :stroke-width="1.8" style="vertical-align:-2px" /> ไฟล์เอกสาร (.pdf / .md / .txt / .docx / .xlsx)</label>
|
||||
<div class="muted label-hint">อัปโหลดไฟล์กลุ่มเป้าหมายหรือรายละเอียดสินค้าที่มีอยู่แล้ว แทนการกรอกช่องด้านบนได้ (กรอกหรืออัปโหลดอย่างใดอย่างหนึ่งก็พอ)</div>
|
||||
<input type="file" multiple accept=".pdf,.md,.txt,.docx,.xlsx" @change="onFiles" />
|
||||
|
||||
<div class="error" v-if="error">{{ error }}</div>
|
||||
<button class="primary" style="margin-top:16px;display:inline-flex;align-items:center;gap:6px" :disabled="busy || (!form.product && !files.length)" @click="create">
|
||||
<div class="error" v-if="error" role="alert">
|
||||
{{ error }}
|
||||
<router-link v-if="failedGroupPath" :to="failedGroupPath" class="btn-back">{{ i18n.t('training') }}</router-link>
|
||||
</div>
|
||||
<button data-testid="create-group" class="primary" style="margin-top:16px;display:inline-flex;align-items:center;gap:6px" :disabled="busy || (!form.product && !files.length)" @click="create">
|
||||
<PlusCircle :size="18" :stroke-width="2" /> {{ busy ? 'กำลังสร้าง persona…' : i18n.t('create') }}
|
||||
</button>
|
||||
</div>
|
||||
@@ -78,12 +91,14 @@ import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { Box, ListChecks, Paperclip, PlusCircle, ArrowLeft, Layers } from 'lucide-vue-next'
|
||||
import { api } from '../api'
|
||||
import { auth } from '../store/auth'
|
||||
import { i18n } from '../i18n'
|
||||
|
||||
const router = useRouter()
|
||||
const form = ref({ product: '', segment: '', description: '', language: 'th' })
|
||||
const form = ref({ product: '', segment: '', description: '', language: 'th', visibility: 'public' })
|
||||
const files = ref([])
|
||||
const error = ref('')
|
||||
const failedGroupPath = ref('')
|
||||
const busy = ref(false)
|
||||
|
||||
function onFiles(e) {
|
||||
@@ -92,26 +107,28 @@ function onFiles(e) {
|
||||
async function create() {
|
||||
busy.value = true
|
||||
error.value = ''
|
||||
failedGroupPath.value = ''
|
||||
try {
|
||||
const fd = new FormData()
|
||||
fd.append('product', form.value.product)
|
||||
fd.append('segment', form.value.segment)
|
||||
fd.append('description', form.value.description)
|
||||
fd.append('language', form.value.language)
|
||||
if (auth.isAdmin) fd.append('visibility', form.value.visibility)
|
||||
files.value.forEach((f) => fd.append('files', f))
|
||||
const data = await api.createGroup(fd)
|
||||
const gid = data.group.id
|
||||
// Auto-generate personas right away (no separate Analyze button needed).
|
||||
try {
|
||||
await api.analyzeGroup(gid)
|
||||
} catch (analyzeErr) {
|
||||
// Even if analyze fails (e.g. LLM key missing), still go to the edit page so the
|
||||
// admin sees the group + an error they can act on.
|
||||
error.value = analyzeErr.message
|
||||
router.push(`/admin/groups/${gid}/edit`)
|
||||
} catch {
|
||||
// Keep the newly created product reachable and show the failure before
|
||||
// the user chooses where to retry it.
|
||||
error.value = i18n.t('requestFailed')
|
||||
failedGroupPath.value = auth.isAdmin ? `/admin/groups/${gid}/edit` : `/training`
|
||||
return
|
||||
}
|
||||
router.push(`/admin/groups/${gid}/edit`)
|
||||
router.push(auth.isAdmin ? `/admin/groups/${gid}/edit` : `/groups/${gid}/personas`)
|
||||
} catch (e) {
|
||||
error.value = i18n.t('groupCreateFailed')
|
||||
} finally {
|
||||
|
||||
57
frontend/src/views/GroupEdit.spec.js
Normal file
57
frontend/src/views/GroupEdit.spec.js
Normal file
@@ -0,0 +1,57 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
api: {
|
||||
getGroup: vi.fn(),
|
||||
listPersonas: vi.fn(),
|
||||
},
|
||||
route: { params: { gid: 'draft-group' } },
|
||||
auth: { isSuperAdmin: false },
|
||||
}))
|
||||
|
||||
vi.mock('../api', () => ({ api: mocks.api }))
|
||||
vi.mock('../store/auth', () => ({ auth: mocks.auth }))
|
||||
vi.mock('vue-router', () => ({ useRoute: () => mocks.route }))
|
||||
|
||||
const RouterLinkStub = {
|
||||
props: ['to'],
|
||||
template: '<a :href="to"><slot /></a>',
|
||||
}
|
||||
|
||||
describe('GroupEdit retry state', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.api.getGroup.mockResolvedValue({
|
||||
group: { id: 'draft-group', status: 'draft', visibility: 'public', title: 'Draft product' },
|
||||
})
|
||||
})
|
||||
|
||||
it('does not call the ready-only persona endpoint for a non-ready group', async () => {
|
||||
const { default: GroupEdit } = await import('./GroupEdit.vue')
|
||||
mount(GroupEdit, {
|
||||
global: {
|
||||
stubs: { 'router-link': RouterLinkStub, PersonaForm: true },
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(mocks.api.listPersonas).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disables reanalysis while the group is already analyzing', async () => {
|
||||
mocks.api.getGroup.mockResolvedValueOnce({
|
||||
group: { id: 'draft-group', status: 'analyzing', visibility: 'public', title: 'Running product' },
|
||||
})
|
||||
const { default: GroupEdit } = await import('./GroupEdit.vue')
|
||||
const wrapper = mount(GroupEdit, {
|
||||
global: {
|
||||
stubs: { 'router-link': RouterLinkStub, PersonaForm: true },
|
||||
},
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('[data-testid="retry-analysis"]').attributes('disabled')).toBeDefined()
|
||||
})
|
||||
})
|
||||
@@ -8,6 +8,46 @@
|
||||
|
||||
<div class="error" v-if="error" style="margin:12px 0">{{ error }}</div>
|
||||
|
||||
<div v-if="group" class="card visibility-card" data-testid="group-visibility-panel">
|
||||
<label for="group-visibility">{{ i18n.t('visibility') }}</label>
|
||||
<div id="group-visibility-hint" class="muted label-hint">{{ i18n.t('visibilityHint') }}</div>
|
||||
<div class="row visibility-row">
|
||||
<select id="group-visibility" data-testid="group-visibility" v-model="visibility" aria-describedby="group-visibility-hint">
|
||||
<option value="public">{{ i18n.t('visibilityPublic') }}</option>
|
||||
<option value="hidden">{{ i18n.t('visibilityHidden') }}</option>
|
||||
<option v-if="auth.isSuperAdmin" value="demo">{{ i18n.t('visibilityDemo') }}</option>
|
||||
</select>
|
||||
<button
|
||||
data-testid="save-group-visibility"
|
||||
class="primary"
|
||||
:disabled="visibilityBusy"
|
||||
@click="saveVisibility"
|
||||
>
|
||||
{{ visibilityBusy ? i18n.t('saving') : i18n.t('saveVisibility') }}
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="visibilityError" class="error" role="alert">{{ visibilityError }}</div>
|
||||
<div v-if="visibilitySaved" class="muted" role="status">{{ i18n.t('visibilitySaved') }}</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="group && ['draft', 'failed', 'analyzing'].includes(group.status)"
|
||||
class="card retry-card"
|
||||
data-testid="retry-analysis-panel"
|
||||
>
|
||||
<strong>{{ i18n.t('groupInstructionAnalyze') }}</strong>
|
||||
<span class="muted">{{ group.status }}</span>
|
||||
<button
|
||||
data-testid="retry-analysis"
|
||||
class="primary"
|
||||
:disabled="analysisBusy || group.status === 'analyzing'"
|
||||
@click="reanalyze"
|
||||
>
|
||||
{{ analysisBusy || group.status === 'analyzing' ? i18n.t('analyzing') : i18n.t('analyze') }}
|
||||
</button>
|
||||
<div v-if="analysisError" class="error" role="alert">{{ analysisError }}</div>
|
||||
</div>
|
||||
|
||||
<!-- Step guidance -->
|
||||
<div class="card guide">
|
||||
<strong>{{ i18n.t('groupInstructions') }}</strong>
|
||||
@@ -61,6 +101,7 @@ import { onMounted, ref } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { Users, Pencil, MessageSquare } from 'lucide-vue-next'
|
||||
import { api } from '../api'
|
||||
import { auth } from '../store/auth'
|
||||
import { i18n } from '../i18n'
|
||||
import PersonaForm from '../components/PersonaForm.vue'
|
||||
|
||||
@@ -71,10 +112,21 @@ const personas = ref([])
|
||||
const busy = ref(false)
|
||||
const error = ref('')
|
||||
const editing = ref(null)
|
||||
const visibility = ref('public')
|
||||
const visibilityBusy = ref(false)
|
||||
const visibilityError = ref('')
|
||||
const visibilitySaved = ref(false)
|
||||
const analysisBusy = ref(false)
|
||||
const analysisError = ref('')
|
||||
|
||||
async function load() {
|
||||
const data = await api.getGroup(gid)
|
||||
group.value = data.group
|
||||
visibility.value = data.group?.visibility || 'public'
|
||||
if (data.group?.status !== 'ready') {
|
||||
personas.value = []
|
||||
return
|
||||
}
|
||||
personas.value = (await api.listPersonas(gid)).personas
|
||||
}
|
||||
function byTier(t) { return personas.value.filter((p) => p.tier === t) }
|
||||
@@ -90,7 +142,41 @@ async function savePersona(payload) {
|
||||
} catch (e) { error.value = i18n.t('requestFailed') }
|
||||
finally { busy.value = false }
|
||||
}
|
||||
onMounted(load)
|
||||
async function saveVisibility() {
|
||||
visibilityBusy.value = true
|
||||
visibilityError.value = ''
|
||||
visibilitySaved.value = false
|
||||
try {
|
||||
const data = await api.updateGroupVisibility(gid, visibility.value)
|
||||
const savedVisibility = data?.group?.visibility || data?.visibility || visibility.value
|
||||
visibility.value = savedVisibility
|
||||
if (group.value) group.value = { ...group.value, visibility: savedVisibility }
|
||||
visibilitySaved.value = true
|
||||
} catch (e) {
|
||||
visibilityError.value = i18n.t('visibilityUpdateFailed')
|
||||
} finally {
|
||||
visibilityBusy.value = false
|
||||
}
|
||||
}
|
||||
async function reanalyze() {
|
||||
analysisBusy.value = true
|
||||
analysisError.value = ''
|
||||
try {
|
||||
await api.analyzeGroup(gid)
|
||||
await load()
|
||||
} catch (e) {
|
||||
analysisError.value = i18n.t('requestFailed')
|
||||
} finally {
|
||||
analysisBusy.value = false
|
||||
}
|
||||
}
|
||||
onMounted(async () => {
|
||||
try {
|
||||
await load()
|
||||
} catch (e) {
|
||||
error.value = i18n.t('requestFailed')
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
@@ -103,6 +189,17 @@ onMounted(load)
|
||||
.badge.tier-b { background: #fef9c3; color: #854d0e; }
|
||||
.badge.tier-c { background: #fee2e2; color: #991b1b; }
|
||||
.guide { background: #eef2ff; border-color: #c7d2fe; margin-bottom: 16px; }
|
||||
.visibility-card { margin: 16px 0; }
|
||||
.retry-card { display: flex; flex-wrap: wrap; align-items: center; gap: 10px; margin: 16px 0; }
|
||||
.retry-card .muted { flex: 1 1 100%; }
|
||||
.retry-card button { margin-left: auto; }
|
||||
.visibility-row { gap: 10px; align-items: flex-end; }
|
||||
.visibility-row select { flex: 1; }
|
||||
.label-hint { font-size: 12px; margin: 4px 0 8px; }
|
||||
@media (max-width: 500px) {
|
||||
.visibility-row { align-items: stretch; flex-direction: column; }
|
||||
.visibility-row button, .visibility-row select { width: 100%; }
|
||||
}
|
||||
.modal-backdrop { position: fixed; inset: 0; background: rgba(15,23,42,.5); display: flex; justify-content: center; align-items: flex-start; padding: 40px 16px; z-index: 50; overflow: auto; }
|
||||
.modal { background: #fff; border-radius: 14px; padding: 24px; width: 100%; max-width: 720px; box-shadow: 0 20px 50px rgba(0,0,0,.25); }
|
||||
</style>
|
||||
|
||||
@@ -35,9 +35,10 @@
|
||||
<span class="badge">{{ item.evidence_count }}</span>
|
||||
</div>
|
||||
<p class="muted">{{ i18n.t('evidenceFrom') }} {{ item.session_ids.length }} session</p>
|
||||
<button class="primary" :disabled="weakBusy || item.evidence_count === 0" @click="lockDimension(key, item)">
|
||||
<button v-if="auth.role === 'user'" class="primary" :disabled="weakBusy || item.evidence_count === 0" @click="lockDimension(key, item)">
|
||||
<Sparkles :size="15" :stroke-width="1.8" /> {{ weakBusy ? i18n.t('creating') : i18n.t('createWeakPersona') }}
|
||||
</button>
|
||||
<span v-else-if="auth.isDemo" class="muted weak-readonly" data-testid="demo-weak-area-readonly">{{ i18n.t('demoWeakAreasReadOnly') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div v-if="!insight.total_sessions" class="empty-inline">
|
||||
@@ -85,6 +86,7 @@ import { computed, onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { LayoutDashboard, Target, BrainCircuit, Sparkles } from 'lucide-vue-next'
|
||||
import { api } from '../api'
|
||||
import { auth } from '../store/auth'
|
||||
import { i18n } from '../i18n'
|
||||
|
||||
const router = useRouter()
|
||||
@@ -123,6 +125,7 @@ async function loadWeakAreas() {
|
||||
}
|
||||
}
|
||||
async function lockDimension(key, item) {
|
||||
if (auth.role !== 'user') return
|
||||
weakBusy.value = true
|
||||
weakError.value = ''
|
||||
try {
|
||||
@@ -165,6 +168,7 @@ onMounted(async () => {
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); gap: 14px; }
|
||||
.dimension { display: flex; flex-direction: column; gap: 8px; }
|
||||
.dimension button { margin-top: auto; display: inline-flex; align-items: center; justify-content: center; gap: 6px; }
|
||||
.weak-readonly { display: block; margin-top: auto; font-size: 13px; }
|
||||
.empty-inline { display: flex; flex-direction: column; gap: 4px; padding: 8px 0; }
|
||||
.session-row {
|
||||
display: flex; justify-content: space-between; align-items: center; gap: 12px;
|
||||
|
||||
@@ -20,10 +20,12 @@
|
||||
<span class="muted">{{ i18n.t('adminPersonaHint') }}</span>
|
||||
</div>
|
||||
|
||||
<div v-for="tier in ['A','B','C']" :key="tier" style="margin:20px 0">
|
||||
<h4>{{ tierLabel(tier) }}</h4>
|
||||
<div class="grid">
|
||||
<div v-for="p in byTier(tier)" :key="p.id" class="card pcard lift">
|
||||
<div v-if="loadError" class="card error-state" role="alert">{{ loadError }}</div>
|
||||
<template v-else>
|
||||
<div v-for="tier in ['A','B','C']" :key="tier" style="margin:20px 0">
|
||||
<h4>{{ tierLabel(tier) }}</h4>
|
||||
<div class="grid">
|
||||
<div v-for="p in byTier(tier)" :key="p.id" class="card pcard lift">
|
||||
<div class="row" style="justify-content:space-between">
|
||||
<strong>{{ p.name }}</strong>
|
||||
<span class="badge" :class="p.my_outcome">{{ outcomeLabel(p.my_outcome) }}</span>
|
||||
@@ -51,13 +53,14 @@
|
||||
<router-link :to="`/groups/${gid}/chat/${p.id}`" style="margin-top:8px">
|
||||
<button class="primary" style="width:100%">{{ i18n.t('personaSummary') }}</button>
|
||||
</router-link>
|
||||
<button class="soft" style="width:100%;margin-top:8px" @click="makeVariant(p)" :disabled="p._busy">
|
||||
<button v-if="!auth.isDemo" class="soft" style="width:100%;margin-top:8px" @click="makeVariant(p)" :disabled="p._busy">
|
||||
{{ p._busy ? i18n.t('creating') : i18n.t('createVariant') }}
|
||||
</button>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
@@ -74,9 +77,16 @@ const router = useRouter()
|
||||
const gid = route.params.gid
|
||||
const personas = ref([])
|
||||
const loading = ref(true)
|
||||
const loadError = ref('')
|
||||
|
||||
async function load() {
|
||||
try { personas.value = (await api.listPersonas(gid)).personas }
|
||||
try {
|
||||
personas.value = (await api.listPersonas(gid)).personas
|
||||
loadError.value = ''
|
||||
} catch (_error) {
|
||||
personas.value = []
|
||||
loadError.value = i18n.t('requestFailed')
|
||||
}
|
||||
finally { loading.value = false }
|
||||
}
|
||||
function byTier(t) { return personas.value.filter((p) => p.tier === t) }
|
||||
@@ -86,6 +96,7 @@ function outcomeLabel(o) {
|
||||
}
|
||||
|
||||
async function makeVariant(p) {
|
||||
if (auth.isDemo) return
|
||||
p._busy = true
|
||||
try {
|
||||
const created = await api.createPersonaVariant(gid, p.id)
|
||||
@@ -114,4 +125,5 @@ onMounted(load)
|
||||
.badge.lost { background: #fee2e2; color: #991b1b; }
|
||||
.badge.not_tried { background: #eef2ff; color: #4338ca; }
|
||||
.guide { background: #eef2ff; border-color: #c7d2fe; margin: 12px 0 16px; }
|
||||
.error-state { color: #991b1b; margin-top: 20px; }
|
||||
</style>
|
||||
|
||||
@@ -35,10 +35,10 @@
|
||||
<strong>{{ i18n.t('coaching') }}</strong>
|
||||
<ul><li v-for="(item, index) in session.debrief.coaching" :key="index">{{ item }}</li></ul>
|
||||
</div>
|
||||
<details v-if="session.debrief.revealed_persona">
|
||||
<details v-if="revealedPersonaEntries.length">
|
||||
<summary>{{ i18n.t('reveal') }}</summary>
|
||||
<div class="reveal-grid">
|
||||
<div v-for="(value, key) in session.debrief.revealed_persona" :key="key" class="rev">
|
||||
<div v-for="([key, value]) in revealedPersonaEntries" :key="key" class="rev">
|
||||
<span class="rk">{{ fieldLabel(key) }}</span>
|
||||
<span class="rv">{{ formatValue(value) }}</span>
|
||||
</div>
|
||||
@@ -46,7 +46,8 @@
|
||||
</details>
|
||||
|
||||
<button
|
||||
v-if="session.group_id && session.persona_id"
|
||||
v-if="!auth.isDemo && session.group_id && session.persona_id"
|
||||
data-testid="create-persona-variant"
|
||||
class="soft variant-btn"
|
||||
@click="makeVariant"
|
||||
:disabled="creating"
|
||||
@@ -63,6 +64,7 @@
|
||||
import { computed, onMounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { api } from '../api'
|
||||
import { auth } from '../store/auth'
|
||||
import { i18n } from '../i18n'
|
||||
|
||||
const route = useRoute()
|
||||
@@ -72,12 +74,20 @@ const loading = ref(true)
|
||||
const error = ref('')
|
||||
const creating = ref(false)
|
||||
const variantMsg = ref('')
|
||||
const SAFE_REVEALED_PERSONA_FIELDS = new Set([
|
||||
'name', 'tier', 'initiation_mode', 'channel', 'profession', 'age_group', 'location', 'product_context',
|
||||
])
|
||||
|
||||
const statusClass = computed(() => session.value?.status === 'active' ? 'active' : session.value?.outcome || 'not_tried')
|
||||
const statusLabel = computed(() => {
|
||||
if (!session.value || session.value.status === 'active') return i18n.t('inProgress')
|
||||
return outcomeLabel(session.value.outcome)
|
||||
})
|
||||
const revealedPersonaEntries = computed(() => {
|
||||
const revealed = session.value?.debrief?.revealed_persona
|
||||
if (!revealed || typeof revealed !== 'object' || Array.isArray(revealed)) return []
|
||||
return Object.entries(revealed).filter(([key]) => SAFE_REVEALED_PERSONA_FIELDS.has(key))
|
||||
})
|
||||
|
||||
function outcomeLabel(outcome) {
|
||||
if (outcome === 'won') return i18n.t('won')
|
||||
@@ -86,8 +96,9 @@ function outcomeLabel(outcome) {
|
||||
}
|
||||
function fieldLabel(key) {
|
||||
const labels = {
|
||||
income: 'รายได้', personality: 'บุคลิก', budget: 'งบประมาณ',
|
||||
opener: 'บทเปิดบทสนทนา', background: 'ภูมิหลัง',
|
||||
name: 'ชื่อ', tier: 'ระดับ', initiation_mode: 'ใครเริ่มก่อน',
|
||||
channel: 'ช่องทาง', profession: 'อาชีพ', age_group: 'ช่วงอายุ',
|
||||
location: 'พื้นที่', product_context: 'บริบทสินค้า',
|
||||
}
|
||||
return labels[key] || key
|
||||
}
|
||||
@@ -112,6 +123,7 @@ function formatValue(value) {
|
||||
}
|
||||
|
||||
async function makeVariant() {
|
||||
if (auth.isDemo) return
|
||||
if (!session.value?.group_id || !session.value?.persona_id) return
|
||||
creating.value = true
|
||||
variantMsg.value = ''
|
||||
|
||||
127
frontend/src/views/Training.spec.js
Normal file
127
frontend/src/views/Training.spec.js
Normal file
@@ -0,0 +1,127 @@
|
||||
/** @vitest-environment jsdom */
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const mocks = vi.hoisted(() => ({
|
||||
api: {
|
||||
listGroups: vi.fn(),
|
||||
createGroup: vi.fn(),
|
||||
analyzeGroup: vi.fn(),
|
||||
},
|
||||
auth: {
|
||||
isAdmin: false,
|
||||
isDemo: false,
|
||||
isSuperAdmin: false,
|
||||
user: null,
|
||||
},
|
||||
router: {
|
||||
push: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('../api', () => ({ api: mocks.api }))
|
||||
vi.mock('../store/auth', () => ({ auth: mocks.auth }))
|
||||
vi.mock('vue-router', () => ({ useRouter: () => mocks.router }))
|
||||
|
||||
const RouterLinkStub = {
|
||||
props: ['to'],
|
||||
template: '<a :href="to"><slot /></a>',
|
||||
}
|
||||
|
||||
describe('Training failure states', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
mocks.auth.isAdmin = false
|
||||
mocks.auth.isDemo = false
|
||||
mocks.auth.user = null
|
||||
})
|
||||
|
||||
it('renders a visible error when products cannot be loaded', async () => {
|
||||
mocks.api.listGroups.mockRejectedValueOnce(new Error('network down'))
|
||||
const { default: Training } = await import('./Training.vue')
|
||||
|
||||
const wrapper = mount(Training, {
|
||||
global: { stubs: { 'router-link': RouterLinkStub } },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('[role="alert"]').text()).toContain('เกิดข้อผิดพลาด')
|
||||
})
|
||||
|
||||
it('keeps automatic-analysis failure visible and does not navigate away', async () => {
|
||||
mocks.api.createGroup.mockResolvedValueOnce({ group: { id: 'failed-group' } })
|
||||
mocks.api.analyzeGroup.mockRejectedValueOnce(new Error('analysis failed'))
|
||||
const { default: GroupBuilder } = await import('./GroupBuilder.vue')
|
||||
|
||||
const wrapper = mount(GroupBuilder, {
|
||||
global: { stubs: { 'router-link': RouterLinkStub } },
|
||||
})
|
||||
await wrapper.get('textarea').setValue('CRM product')
|
||||
await wrapper.get('[data-testid="create-group"]').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('[role="alert"]').text()).toContain('เกิดข้อผิดพลาด')
|
||||
expect(mocks.router.push).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disables reanalysis for an owner product while analysis is running', async () => {
|
||||
mocks.api.listGroups.mockResolvedValue({
|
||||
groups: [{ id: 'stuck-group', title: 'CRM', status: 'analyzing', is_owned: true }],
|
||||
})
|
||||
const { default: Training } = await import('./Training.vue')
|
||||
|
||||
const wrapper = mount(Training, {
|
||||
global: { stubs: { 'router-link': RouterLinkStub } },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
const analyzing = wrapper.get('.train-card button.primary')
|
||||
expect(analyzing.attributes('disabled')).toBeDefined()
|
||||
await analyzing.trigger('click')
|
||||
expect(mocks.api.analyzeGroup).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('renders a numeric persona count when a group includes persona rows', async () => {
|
||||
mocks.auth.isAdmin = true
|
||||
mocks.api.listGroups.mockResolvedValue({
|
||||
groups: [{
|
||||
id: 'ready-group',
|
||||
title: 'CRM',
|
||||
status: 'ready',
|
||||
personas: [{ id: 'p-1' }, { id: 'p-2' }],
|
||||
}],
|
||||
})
|
||||
const { default: Training } = await import('./Training.vue')
|
||||
|
||||
const wrapper = mount(Training, {
|
||||
global: { stubs: { 'router-link': RouterLinkStub } },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.get('.persona-count').text()).toMatch(/^2\s/)
|
||||
})
|
||||
|
||||
it.each(['admin', 'super_admin'])(
|
||||
'shows a private owned product after its owner is promoted to %s',
|
||||
async (role) => {
|
||||
mocks.auth.isAdmin = true
|
||||
mocks.auth.isSuperAdmin = role === 'super_admin'
|
||||
mocks.auth.user = { id: 'promoted-owner', role }
|
||||
mocks.api.listGroups.mockResolvedValue({
|
||||
groups: [{
|
||||
id: 'owned-ready-group', title: 'Owner CRM', status: 'ready',
|
||||
visibility: 'private', is_owned: true, personas: [{ id: 'p-1' }],
|
||||
}],
|
||||
})
|
||||
const { default: Training } = await import('./Training.vue')
|
||||
|
||||
const wrapper = mount(Training, {
|
||||
global: { stubs: { 'router-link': RouterLinkStub } },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('Owner CRM')
|
||||
expect(wrapper.find('a[href="/groups/owned-ready-group/personas"]').exists()).toBe(true)
|
||||
},
|
||||
)
|
||||
})
|
||||
@@ -6,37 +6,59 @@
|
||||
<div class="page-sub">{{ i18n.t('trainingSubtitle') }}</div>
|
||||
</div>
|
||||
<div class="row" style="gap:10px">
|
||||
<router-link v-if="auth.isAdmin" to="/admin/new-group">
|
||||
<router-link v-if="auth.isAdmin" :to="newProductPath">
|
||||
<button class="primary"><Plus :size="18" :stroke-width="2" /> {{ i18n.t('addProduct') }}</button>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
v-if="auth.isDemo"
|
||||
class="card demo-trial-banner"
|
||||
:class="`demo-trial-${trialState}`"
|
||||
data-testid="demo-trial-banner"
|
||||
role="status"
|
||||
>
|
||||
<strong>{{ demoTrialTitle }}</strong>
|
||||
<span>{{ demoTrialMessage }}</span>
|
||||
<time v-if="trialExpiryLabel" :datetime="auth.user?.trial_expires_at">{{ trialExpiryLabel }}</time>
|
||||
</div>
|
||||
|
||||
<div v-if="groupsLoading" class="card" style="min-height:80px">
|
||||
<div class="skeleton" style="height:50px"></div>
|
||||
</div>
|
||||
<div v-else-if="groups.length === 0" class="card empty-state">
|
||||
<div v-else-if="groupsError" class="card error-state" role="alert">
|
||||
{{ groupsError }}
|
||||
</div>
|
||||
<div v-else-if="groups.length === 0 && (auth.isAdmin || auth.isDemo)" class="card empty-state">
|
||||
<span class="empty-illust"><PackageOpen :size="26" :stroke-width="1.6" /></span>
|
||||
<strong>{{ i18n.t('noTraining') }}</strong>
|
||||
<span v-if="auth.isAdmin">{{ i18n.t('noProductsHint') }}</span>
|
||||
<router-link v-if="auth.isAdmin" to="/admin/new-group">
|
||||
<span v-if="auth.isDemo">{{ i18n.t('demoNoTraining') }}</span>
|
||||
<span v-else>{{ i18n.t('noProductsHint') }}</span>
|
||||
<router-link v-if="!auth.isDemo" :to="newProductPath">
|
||||
<button class="primary" style="margin-top:10px"><Plus :size="16" :stroke-width="2" /> {{ i18n.t('addProduct') }}</button>
|
||||
</router-link>
|
||||
<span v-else>ยังไม่มีสินค้าให้ฝึก กรุณารอผู้ดูแลเพิ่มสินค้าก่อน</span>
|
||||
</div>
|
||||
|
||||
<!-- Own (private) products -->
|
||||
<template v-if="ownGroups.length">
|
||||
<h3 class="section-title"><UserRound :size="18" :stroke-width="1.8" style="vertical-align:-3px" /> {{ i18n.t('ownProducts') }}</h3>
|
||||
<div class="grid">
|
||||
<template v-if="!groupsError && !auth.isDemo && (!auth.isAdmin || ownGroups.length)">
|
||||
<div class="section-heading">
|
||||
<h3 class="section-title"><UserRound :size="18" :stroke-width="1.8" style="vertical-align:-3px" /> {{ i18n.t('ownProducts') }}</h3>
|
||||
<router-link :to="newProductPath">
|
||||
<button class="soft add-product-button"><Plus :size="16" :stroke-width="2" /> {{ i18n.t('addProduct') }}</button>
|
||||
</router-link>
|
||||
</div>
|
||||
<div v-if="ownGroups.length" class="grid">
|
||||
<div v-for="g in ownGroups" :key="g.id" class="card lift train-card">
|
||||
<div class="row" style="justify-content:space-between;align-items:flex-start">
|
||||
<router-link
|
||||
:to="auth.isAdmin ? `/admin/groups/${g.id}/edit` : `/groups/${g.id}/personas`"
|
||||
v-if="g.status === 'ready'"
|
||||
:to="`/groups/${g.id}/personas`"
|
||||
style="text-decoration:none;color:inherit;min-width:0"
|
||||
>
|
||||
<strong class="g-title">{{ g.title }}</strong>
|
||||
</router-link>
|
||||
<strong v-else class="g-title">{{ g.title }}</strong>
|
||||
<div class="row" style="gap:6px;align-items:center">
|
||||
<span class="badge" :class="g.status === 'ready' ? 'ready' : 'draft'">{{ g.status }}</span>
|
||||
<button
|
||||
@@ -50,30 +72,50 @@
|
||||
</div>
|
||||
</div>
|
||||
<router-link
|
||||
:to="auth.isAdmin ? `/admin/groups/${g.id}/edit` : `/groups/${g.id}/personas`"
|
||||
v-if="g.status === 'ready'"
|
||||
:to="`/groups/${g.id}/personas`"
|
||||
style="text-decoration:none;color:inherit;flex:1;display:flex;flex-direction:column"
|
||||
>
|
||||
<div class="muted g-product">{{ productName(g) }}</div>
|
||||
<button class="primary" style="width:100%;margin-top:14px">
|
||||
{{ auth.isAdmin ? (g.status === 'ready' ? i18n.t('managePersonas') : i18n.t('analyze')) : i18n.t('selectPersona') }}
|
||||
</button>
|
||||
<button class="primary" style="width:100%;margin-top:14px">{{ i18n.t('selectPersona') }}</button>
|
||||
</router-link>
|
||||
<div v-else style="flex:1;display:flex;flex-direction:column">
|
||||
<div class="muted g-product">{{ productName(g) }}</div>
|
||||
<button
|
||||
class="primary"
|
||||
style="width:100%;margin-top:14px"
|
||||
:disabled="analysisBusy[g.id] || g.status === 'analyzing'"
|
||||
@click="reanalyzeProduct(g)"
|
||||
>
|
||||
{{ analysisBusy[g.id] || g.status === 'analyzing' ? i18n.t('analyzing') : i18n.t('analyze') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="card empty-inline product-empty">
|
||||
<span class="empty-illust"><PackageOpen :size="24" :stroke-width="1.6" /></span>
|
||||
<strong>{{ i18n.t('noOwnProducts') }}</strong>
|
||||
<span>{{ i18n.t('noOwnProductsHint') }}</span>
|
||||
<router-link :to="newProductPath">
|
||||
<button class="primary" style="margin-top:10px"><Plus :size="16" :stroke-width="2" /> {{ i18n.t('addProduct') }}</button>
|
||||
</router-link>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Org products (สินค้าขององค์กร) -->
|
||||
<template v-if="orgGroups.length">
|
||||
<template v-if="!groupsError && orgGroups.length">
|
||||
<h3 class="section-title"><Building2 :size="18" :stroke-width="1.8" style="vertical-align:-3px" /> {{ i18n.t('orgProducts') }}</h3>
|
||||
<div class="grid">
|
||||
<div v-for="g in orgGroups" :key="g.id" class="card lift train-card">
|
||||
<div class="row" style="justify-content:space-between;align-items:flex-start">
|
||||
<router-link
|
||||
v-if="auth.isAdmin || g.status === 'ready'"
|
||||
:to="auth.isAdmin ? `/admin/groups/${g.id}/edit` : `/groups/${g.id}/personas`"
|
||||
style="text-decoration:none;color:inherit;min-width:0"
|
||||
>
|
||||
<strong class="g-title">{{ g.title }}</strong>
|
||||
</router-link>
|
||||
<strong v-else class="g-title">{{ g.title }}</strong>
|
||||
<div class="row" style="gap:6px;align-items:center">
|
||||
<span class="badge" :class="g.status === 'ready' ? 'ready' : 'draft'">{{ g.status }}</span>
|
||||
<button
|
||||
@@ -87,6 +129,7 @@
|
||||
</div>
|
||||
</div>
|
||||
<router-link
|
||||
v-if="auth.isAdmin || g.status === 'ready'"
|
||||
:to="auth.isAdmin ? `/admin/groups/${g.id}/edit` : `/groups/${g.id}/personas`"
|
||||
style="text-decoration:none;color:inherit;flex:1;display:flex;flex-direction:column"
|
||||
>
|
||||
@@ -98,71 +141,66 @@
|
||||
{{ auth.isAdmin ? (g.status === 'ready' ? i18n.t('managePersonas') : i18n.t('analyze')) : i18n.t('selectPersona') }}
|
||||
</button>
|
||||
</router-link>
|
||||
<div v-else style="flex:1;display:flex;flex-direction:column">
|
||||
<div class="muted g-product">{{ productName(g) }}</div>
|
||||
<span class="muted" style="margin-top:auto;padding-top:14px">{{ g.status }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Private personas (Persona ของฉัน) -->
|
||||
<div class="card private" style="margin-top:24px">
|
||||
<h3 style="margin-top:0;margin-bottom:4px"><UserRound :size="18" :stroke-width="1.8" style="vertical-align:-3px" /> {{ i18n.t('myPersonas') }}</h3>
|
||||
<p class="muted" style="margin-top:0">{{ i18n.t('privatePersonasIntro') }}</p>
|
||||
|
||||
<div class="manual">
|
||||
<textarea v-model="manualSpec" rows="3" :placeholder="i18n.t('manualPersonaPlaceholder')"></textarea>
|
||||
<button class="primary" :disabled="personaBusy || !manualSpec.trim()" @click="generateManual" style="align-self:flex-start;display:inline-flex;align-items:center;gap:6px">
|
||||
<Sparkles :size="15" :stroke-width="1.8" /> {{ personaBusy ? i18n.t('creating') : i18n.t('generatePersona') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div v-if="personaError" class="error" style="margin:12px 0">{{ personaError }}</div>
|
||||
<div v-if="personasLoading" class="skeleton" style="height:80px"></div>
|
||||
<div v-else-if="!personas.length" class="empty-inline">
|
||||
<strong>{{ i18n.t('noPrivatePersonas') }}</strong>
|
||||
<span>{{ i18n.t('privatePersonasHint') }}</span>
|
||||
</div>
|
||||
<div v-else class="grid">
|
||||
<div v-for="persona in personas" :key="persona.id" class="card persona">
|
||||
<div class="row" style="justify-content:space-between;align-items:center">
|
||||
<strong>{{ persona.name }}</strong>
|
||||
<span class="badge">{{ persona.tier }}</span>
|
||||
</div>
|
||||
<div class="muted">{{ persona.profession }} · {{ persona.age_group }} · {{ persona.location }}</div>
|
||||
<div class="muted">{{ persona.product_context }}</div>
|
||||
<router-link :to="`/groups/${personaGroupId}/chat/${persona.id}`" style="margin-top:auto">
|
||||
<button class="primary" style="width:100%">{{ i18n.t('chat') }}</button>
|
||||
</router-link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import { Target, Plus, Trash2, Users, PackageOpen, UserRound, Building2, Sparkles } from 'lucide-vue-next'
|
||||
import { Target, Plus, Trash2, Users, PackageOpen, UserRound, Building2 } from 'lucide-vue-next'
|
||||
import { api } from '../api'
|
||||
import { auth } from '../store/auth'
|
||||
import { i18n } from '../i18n'
|
||||
|
||||
const groups = ref([])
|
||||
const groupsLoading = ref(true)
|
||||
const groupsError = ref('')
|
||||
const analysisBusy = ref({})
|
||||
|
||||
const ownGroups = computed(() => groups.value.filter((g) => g.owner_user_id && g.owner_user_id === (auth.user && auth.user.id)))
|
||||
const orgGroups = computed(() => groups.value.filter((g) => !(g.owner_user_id && g.owner_user_id === (auth.user && auth.user.id))))
|
||||
const trialState = computed(() => {
|
||||
if (!auth.isDemo) return 'none'
|
||||
const user = auth.user || {}
|
||||
if (!user.trial_started_at) return 'first-login'
|
||||
const expiry = user.trial_expires_at ? Date.parse(user.trial_expires_at) : NaN
|
||||
return Number.isFinite(expiry) && expiry <= Date.now() ? 'expired' : 'active'
|
||||
})
|
||||
const demoTrialTitle = computed(() => i18n.t({
|
||||
'first-login': 'demoTrialFirstLoginTitle',
|
||||
active: 'demoTrialActiveTitle',
|
||||
expired: 'demoTrialExpiredTitle',
|
||||
}[trialState.value] || 'demoTrialActiveTitle'))
|
||||
const demoTrialMessage = computed(() => i18n.t({
|
||||
'first-login': 'demoTrialFirstLoginText',
|
||||
active: 'demoTrialActiveText',
|
||||
expired: 'demoTrialExpiredText',
|
||||
}[trialState.value] || 'demoTrialActiveText'))
|
||||
const trialExpiryLabel = computed(() => {
|
||||
if (trialState.value !== 'active' || !auth.user?.trial_expires_at) return ''
|
||||
const expiry = new Date(auth.user.trial_expires_at)
|
||||
if (Number.isNaN(expiry.getTime())) return ''
|
||||
return `${i18n.t('demoTrialExpiresAt')}: ${expiry.toLocaleString(i18n.locale === 'th' ? 'th-TH' : 'en-GB', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'short',
|
||||
})}`
|
||||
})
|
||||
|
||||
// Private personas (merged from MyPersonas.vue)
|
||||
const personaGroupId = ref('')
|
||||
const personas = ref([])
|
||||
const manualSpec = ref('')
|
||||
const personasLoading = ref(true)
|
||||
const personaBusy = ref(false)
|
||||
const personaError = ref('')
|
||||
const newProductPath = computed(() => auth.isAdmin ? '/admin/new-group' : '/products/new')
|
||||
const ownGroups = computed(() => groups.value.filter((g) => g.is_owned === true))
|
||||
const orgGroups = computed(() => groups.value.filter((g) => g.is_owned !== true))
|
||||
|
||||
function productName(g) {
|
||||
return (g.sales_kit && g.sales_kit.productName) || (g.input && g.input.product) || ''
|
||||
}
|
||||
function personaCount(g) {
|
||||
return (g.personas || g.persona_count || 0)
|
||||
if (Array.isArray(g.personas)) return g.personas.length
|
||||
return Number.isInteger(g.persona_count) && g.persona_count >= 0 ? g.persona_count : 0
|
||||
}
|
||||
|
||||
async function removeGroup(g) {
|
||||
@@ -175,51 +213,50 @@ async function removeGroup(g) {
|
||||
}
|
||||
}
|
||||
|
||||
async function reanalyzeProduct(g) {
|
||||
if (analysisBusy.value[g.id]) return
|
||||
analysisBusy.value = { ...analysisBusy.value, [g.id]: true }
|
||||
try {
|
||||
await api.analyzeGroup(g.id)
|
||||
await loadGroups()
|
||||
} catch (e) {
|
||||
alert(i18n.t('requestFailed'))
|
||||
} finally {
|
||||
const next = { ...analysisBusy.value }
|
||||
delete next[g.id]
|
||||
analysisBusy.value = next
|
||||
}
|
||||
}
|
||||
|
||||
async function loadGroups() {
|
||||
groupsError.value = ''
|
||||
try {
|
||||
const all = (await api.listGroups()).groups || []
|
||||
// Admins see all groups (incl. drafts to manage/analyze); trainees only ready ones.
|
||||
groups.value = auth.isAdmin ? all : all.filter((g) => g.status === 'ready')
|
||||
// Demo accounts may train only on explicitly published demo groups.
|
||||
if (auth.isDemo) {
|
||||
groups.value = all.filter((g) => g.status === 'ready' && g.visibility === 'demo')
|
||||
} else {
|
||||
// The API already scopes public products and the user's private products.
|
||||
// Keep non-ready private products visible so the owner can retry analysis.
|
||||
groups.value = all
|
||||
}
|
||||
} catch {
|
||||
groups.value = []
|
||||
groupsError.value = i18n.t('requestFailed')
|
||||
} finally {
|
||||
groupsLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function loadPersonas() {
|
||||
try {
|
||||
const data = await api.myPersonas()
|
||||
personaGroupId.value = data.group?.id || ''
|
||||
personas.value = data.personas || []
|
||||
} catch (e) {
|
||||
personaError.value = i18n.t('requestFailed')
|
||||
} finally {
|
||||
personasLoading.value = false
|
||||
}
|
||||
}
|
||||
async function generateManual() {
|
||||
personaBusy.value = true
|
||||
personaError.value = ''
|
||||
try {
|
||||
await api.generatePersona({ mode: 'manual', spec: { description: manualSpec.value.trim() } })
|
||||
manualSpec.value = ''
|
||||
await loadPersonas()
|
||||
} catch (e) {
|
||||
personaError.value = i18n.t('requestFailed')
|
||||
} finally {
|
||||
personaBusy.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadGroups()
|
||||
loadPersonas()
|
||||
})
|
||||
onMounted(loadGroups)
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr)); gap: 16px; }
|
||||
.train-card { display: flex; flex-direction: column; }
|
||||
.section-title { margin: 22px 0 12px; font-size: 15px; color: var(--ink); }
|
||||
.section-heading { display: flex; align-items: center; justify-content: space-between; gap: 12px; margin: 22px 0 12px; }
|
||||
.section-title { margin: 0; font-size: 15px; color: var(--ink); }
|
||||
.add-product-button { display: inline-flex; align-items: center; gap: 6px; }
|
||||
.g-title { font-size: var(--text-md); letter-spacing: var(--tracking-tight); display: inline-block; }
|
||||
.g-product { margin: 6px 0 0; font-size: 13px; line-height: 1.5; }
|
||||
.persona-count { display: inline-flex; align-items: center; gap: 6px; color: var(--muted); font-size: 13px; font-weight: 600; }
|
||||
@@ -235,7 +272,14 @@ button.soft { background: var(--accent-soft); border-color: transparent; color:
|
||||
justify-content: center; color: var(--muted); cursor: pointer;
|
||||
}
|
||||
.del-btn:hover { background: var(--red-soft); border-color: #fecaca; color: var(--red); }
|
||||
.private .manual { display: flex; flex-direction: column; gap: 10px; margin-bottom: 14px; }
|
||||
.persona { display: flex; flex-direction: column; gap: 8px; min-height: 150px; }
|
||||
.empty-inline { display: flex; flex-direction: column; gap: 4px; }
|
||||
.product-empty { align-items: flex-start; }
|
||||
.demo-trial-banner { display: flex; flex-direction: column; gap: 4px; margin: 16px 0 20px; overflow-wrap: anywhere; }
|
||||
.demo-trial-banner time { font-size: 13px; font-weight: 650; }
|
||||
.demo-trial-first-login { background: #eff6ff; border-color: #bfdbfe; color: #1e3a8a; }
|
||||
.demo-trial-active { background: #f0fdf4; border-color: #bbf7d0; color: #166534; }
|
||||
.demo-trial-expired { background: #fff1f2; border-color: #fecdd3; color: #9f1239; }
|
||||
@media (max-width: 640px) {
|
||||
.demo-trial-banner { margin-top: 12px; margin-bottom: 16px; padding: 14px; }
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,6 +6,7 @@ const admin = {
|
||||
name: 'Fixture Admin',
|
||||
role: 'admin',
|
||||
must_setup: false,
|
||||
accepted_terms: true,
|
||||
}
|
||||
|
||||
const persona = {
|
||||
@@ -17,9 +18,10 @@ const persona = {
|
||||
initiation_mode: 'customer',
|
||||
}
|
||||
|
||||
async function installFixtureApi(page, { chat = false } = {}) {
|
||||
async function installFixtureApi(page, { chat = false, groupStatus = 'ready' } = {}) {
|
||||
await page.addInitScript(() => localStorage.setItem('st_token', 'fixture-token'))
|
||||
let finishCalls = 0
|
||||
let sendCalls = 0
|
||||
let currentGroupStatus = groupStatus
|
||||
|
||||
await page.route('**/api/**', async (route) => {
|
||||
const request = route.request()
|
||||
@@ -32,11 +34,19 @@ async function installFixtureApi(page, { chat = false } = {}) {
|
||||
return route.fulfill({ json: { user: admin } })
|
||||
}
|
||||
if (method === 'GET' && path === '/api/groups') {
|
||||
return route.fulfill({ json: { groups: [{ id: 'g1', title: 'CRM assistant', status: 'ready', persona_count: 15, input: { product: 'CRM assistant' } }] } })
|
||||
return route.fulfill({ json: { groups: [{ id: 'g1', title: 'CRM assistant', status: currentGroupStatus, persona_count: 15, input: { product: 'CRM assistant' } }] } })
|
||||
}
|
||||
if (method === 'GET' && path === '/api/groups/g1/report') {
|
||||
return route.fulfill({ json: { title: 'CRM assistant', markdown: '# CRM assistant\n\n## Sales Kit\n\nPractice report' } })
|
||||
if (method === 'GET' && path === '/api/groups/g1') {
|
||||
return route.fulfill({ json: { group: { id: 'g1', title: 'CRM assistant', status: currentGroupStatus, visibility: 'public' } } })
|
||||
}
|
||||
if (method === 'POST' && path === '/api/groups/g1/analyze') {
|
||||
currentGroupStatus = 'ready'
|
||||
return route.fulfill({ json: { group: { id: 'g1', title: 'CRM assistant', status: currentGroupStatus, visibility: 'public' } } })
|
||||
}
|
||||
if (method === 'GET' && path === '/api/groups/g1/personas') {
|
||||
return route.fulfill({ json: { personas: [{ ...persona, tier: 'A', difficulty: 1, location: 'Bangkok', personality: 'Practical', my_outcome: 'not_tried' }] } })
|
||||
}
|
||||
|
||||
if (method === 'GET' && path === '/api/analytics') {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
@@ -65,14 +75,14 @@ async function installFixtureApi(page, { chat = false } = {}) {
|
||||
return route.fulfill({ json: { session: { id: 's1', messages: [] } } })
|
||||
}
|
||||
if (chat && method === 'POST' && path === '/api/chat/g1/personas/p1/chat/send') {
|
||||
return route.fulfill({ json: { messages: [{ role: 'customer', text: 'Tell me more.' }], finished: false } })
|
||||
}
|
||||
if (chat && method === 'POST' && path === '/api/chat/g1/personas/p1/chat/finish') {
|
||||
finishCalls += 1
|
||||
sendCalls += 1
|
||||
return route.fulfill({
|
||||
json: {
|
||||
session: { id: 's1', messages: [{ role: 'customer', text: 'Tell me more.' }], status: 'finished' },
|
||||
debrief: { outcome: 'won', score: 82, pain: 'manual work', why: 'clear value', coaching: [] },
|
||||
finished: true,
|
||||
outcome: 'won',
|
||||
messages: [{ role: 'customer', text: 'Tell me more.' }],
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -80,7 +90,7 @@ async function installFixtureApi(page, { chat = false } = {}) {
|
||||
return route.fulfill({ status: 404, json: { error: `unmocked fixture route: ${method} ${path}` } })
|
||||
})
|
||||
|
||||
return () => finishCalls
|
||||
return () => sendCalls
|
||||
}
|
||||
|
||||
async function installUserJourneyApi(page) {
|
||||
@@ -93,7 +103,36 @@ async function installUserJourneyApi(page) {
|
||||
if (!path.startsWith('/api/')) return route.continue()
|
||||
|
||||
if (method === 'GET' && path === '/api/auth/me') {
|
||||
return route.fulfill({ json: { user: { id: 'trainee', username: 'trainee', name: 'Fixture Trainee', role: 'user', must_setup: false } } })
|
||||
return route.fulfill({ json: { user: { id: 'trainee', username: 'trainee', name: 'Fixture Trainee', role: 'user', must_setup: false, accepted_terms: true } } })
|
||||
}
|
||||
if (method === 'GET' && path === '/api/groups') {
|
||||
return route.fulfill({
|
||||
json: {
|
||||
groups: [{
|
||||
id: 'g-user',
|
||||
title: 'CRM assistant',
|
||||
status: 'ready',
|
||||
persona_count: 1,
|
||||
input: { product: 'CRM assistant' },
|
||||
is_owned: true,
|
||||
}],
|
||||
},
|
||||
})
|
||||
}
|
||||
if (method === 'GET' && path === '/api/groups/g-user') {
|
||||
return route.fulfill({ json: { group: { id: 'g-user', title: 'CRM assistant', status: 'ready', visibility: 'private', is_owned: true } } })
|
||||
}
|
||||
if (method === 'GET' && path === '/api/groups/g-user/personas') {
|
||||
return route.fulfill({ json: { personas: [{ ...persona, tier: 'A', difficulty: 1, location: 'Bangkok', product_context: 'CRM assistant', my_outcome: 'not_tried' }] } })
|
||||
}
|
||||
if (method === 'GET' && path === '/api/groups/g-user/personas/p1') {
|
||||
return route.fulfill({ json: { persona: { ...persona, tier: 'A', difficulty: 1, location: 'Bangkok', product_context: 'CRM assistant' } } })
|
||||
}
|
||||
if (method === 'GET' && path === '/api/chat/g-user/personas/p1/chat/resume') {
|
||||
return route.fulfill({ status: 404, json: { error: 'no active session' } })
|
||||
}
|
||||
if (method === 'POST' && path === '/api/chat/g-user/personas/p1/chat/start') {
|
||||
return route.fulfill({ json: { session: { id: 'user-s1', messages: [] } } })
|
||||
}
|
||||
if (method === 'GET' && path === '/api/me/board') {
|
||||
return route.fulfill({ json: { board: [{ id: 'p1', persona_name: 'Acme decision maker', my_outcome: 'won' }] } })
|
||||
@@ -102,32 +141,22 @@ async function installUserJourneyApi(page) {
|
||||
return route.fulfill({ json: { sessions: [{ id: 's1', persona_name: 'Acme decision maker', status: 'finished', outcome: 'won' }] } })
|
||||
}
|
||||
if (method === 'GET' && path === '/api/chat/sessions/s1') {
|
||||
return route.fulfill({ json: { session: { id: 's1', persona_name: 'Acme decision maker', status: 'finished', outcome: 'won', messages: [{ role: 'customer', text: 'Tell me more.' }], debrief: { outcome: 'won', score: 82, pain: 'manual work', why: 'clear value', failurePoints: [], coaching: [] } } } })
|
||||
return route.fulfill({ json: { session: { id: 's1', persona_name: 'Acme decision maker', status: 'finished', outcome: 'won', messages: [{ role: 'customer', text: 'Tell me more.' }], debrief: { outcome: 'won', score: 82, pain: 'manual work', why: 'clear value', failurePoints: [], coaching: [], revealed_persona: { name: 'Acme decision maker', tier: 'A', secret_formula: 'internal formula', pains: ['hidden pain'] } } } } })
|
||||
}
|
||||
if (method === 'GET' && path === '/api/me/weak-areas') {
|
||||
return route.fulfill({ json: { insight: { total_sessions: 1, wins: 1, losses: 0, dimensions: { objection_handling: { label: 'Objection handling', evidence_count: 1, session_ids: ['s1'] } } } } })
|
||||
}
|
||||
if (method === 'POST' && path === '/api/me/personas/generate') {
|
||||
return route.fulfill({ json: { persona: { id: 'private-p1', name: 'Locked pain persona' } } })
|
||||
}
|
||||
if (method === 'GET' && path === '/api/me/personas') {
|
||||
return route.fulfill({ json: { group: { id: 'private-group' }, personas: [{ id: 'private-p1', name: 'Locked pain persona', tier: 'B', profession: 'Buyer', age_group: '35-44', location: 'Bangkok', product_context: 'CRM assistant' }] } })
|
||||
}
|
||||
|
||||
return route.fulfill({ status: 404, json: { error: `unmocked user fixture route: ${method} ${path}` } })
|
||||
return route.fulfill({ status: 404, json: { error: `unmocked fixture route: ${method} ${path}` } })
|
||||
})
|
||||
}
|
||||
|
||||
test('admin can open the training list and human-readable group report', async ({ page }) => {
|
||||
test('admin can open the training list and manage product personas', async ({ page }) => {
|
||||
await installFixtureApi(page)
|
||||
await page.goto('/training')
|
||||
|
||||
await expect(page.getByRole('link', { name: 'CRM assistant', exact: true })).toBeVisible()
|
||||
await expect(page.getByText(/15 personas|15 บุคคลต้นแบบ/)).toBeVisible()
|
||||
await page.goto('/admin/groups/g1/report')
|
||||
await expect(page.getByRole('heading', { name: /รายงานกลุ่มบุคคลต้นแบบ|Group report/ })).toBeVisible()
|
||||
await expect(page.getByText('Practice report')).toBeVisible()
|
||||
await expect(page.getByRole('button', { name: /ดาวน์โหลดรายงาน|Download report/ })).toBeVisible()
|
||||
await expect(page.getByText(/15 persona(s)?|15 บุคคลต้นแบบ/)).toBeVisible()
|
||||
await page.goto('/admin/groups/g1/edit')
|
||||
await expect(page.getByRole('heading', { name: /จัดการ persona|Manage personas/ })).toBeVisible()
|
||||
await expect(page.getByText('CRM assistant')).toBeVisible()
|
||||
await expect(page.getByTestId('group-visibility-panel')).toBeVisible()
|
||||
|
||||
await page.goto('/')
|
||||
await expect(page.getByText(/Sessions|ครั้งที่ฝึก/, { exact: true })).toBeVisible()
|
||||
@@ -139,37 +168,56 @@ test('admin can open the training list and human-readable group report', async (
|
||||
expect(widthState.scrollWidth).toBeLessThanOrEqual(widthState.width)
|
||||
})
|
||||
|
||||
test('chat preview supports manual finish once and removes the finish action after completion', async ({ page }) => {
|
||||
const getFinishCalls = await installFixtureApi(page, { chat: true })
|
||||
test('admin can retry a failed product analysis', async ({ page }) => {
|
||||
await installFixtureApi(page, { groupStatus: 'failed' })
|
||||
await page.goto('/training')
|
||||
|
||||
await page.getByRole('link', { name: 'CRM assistant', exact: true }).click()
|
||||
await expect(page).toHaveURL(/\/admin\/groups\/g1\/edit$/)
|
||||
await expect(page.getByTestId('retry-analysis')).toBeVisible()
|
||||
|
||||
await page.getByTestId('retry-analysis').click()
|
||||
await expect(page.getByTestId('retry-analysis')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('chat closes automatically after the customer makes a decision', async ({ page }) => {
|
||||
const getSendCalls = await installFixtureApi(page, { chat: true })
|
||||
await page.goto('/groups/g1/chat/p1')
|
||||
|
||||
await expect(page.getByText(/Preview mode|โหมด Preview/i)).toBeVisible()
|
||||
await expect(page.getByText(/Choose a scenario|เลือกสถานการณ์/)).toBeVisible()
|
||||
await page.getByRole('button', { name: /เริ่มต้น|Start/ }).click()
|
||||
await expect(page.getByText('Acme decision maker')).toBeVisible()
|
||||
await page.locator('input[placeholder="ส่ง"], input[placeholder="Send"]').fill('I can help.')
|
||||
await page.getByRole('button', { name: /ส่ง|Send/ }).click()
|
||||
page.once('dialog', (dialog) => dialog.accept())
|
||||
await page.getByRole('button', { name: /สรุปผล|Finish/ }).click()
|
||||
|
||||
await expect(page.getByText(/ผลลัพธ์และข้อเสนอแนะ|Result & Coaching/)).toBeVisible()
|
||||
expect(getFinishCalls()).toBe(1)
|
||||
await expect(page.getByRole('button', { name: /สรุปผล|Finish/ })).toHaveCount(0)
|
||||
await expect(page.locator('.result-banner strong')).toHaveText(/Won|ชนะ|ปิดการขายได้/)
|
||||
expect(getSendCalls()).toBe(1)
|
||||
await expect(page.locator('input[placeholder="ส่ง"], input[placeholder="Send"]')).toHaveCount(0)
|
||||
})
|
||||
|
||||
test('trainee can move from board to detail, weak area, and private persona', async ({ page }) => {
|
||||
test('trainee can move from board to product personas and start training', async ({ page }) => {
|
||||
await installUserJourneyApi(page)
|
||||
await page.goto('/my/board')
|
||||
|
||||
await expect(page.getByText('Acme decision maker')).toBeVisible()
|
||||
await page.getByRole('link', { name: /ดูรายละเอียด|View details/ }).click()
|
||||
await expect(page.getByText('Tell me more.')).toBeVisible()
|
||||
await page.locator('summary').click()
|
||||
await expect(page.locator('.reveal-grid .rv').filter({ hasText: 'Acme decision maker' })).toBeVisible()
|
||||
await expect(page.getByText('internal formula')).toHaveCount(0)
|
||||
await expect(page.getByText('hidden pain')).toHaveCount(0)
|
||||
|
||||
await page.goto('/my/weak-areas')
|
||||
await expect(page.getByText('Objection handling')).toBeVisible()
|
||||
await page.getByRole('button', { name: /สร้างเพื่อฝึกจุดนี้|Create for this weakness/ }).click()
|
||||
await page.goto('/training')
|
||||
await expect(page.getByRole('link', { name: 'CRM assistant', exact: true }).first()).toBeVisible()
|
||||
await page.getByRole('link', { name: 'CRM assistant', exact: true }).first().click()
|
||||
|
||||
await expect(page).toHaveURL(/\/my\/personas$/)
|
||||
await expect(page.getByText('Locked pain persona')).toBeVisible()
|
||||
await expect(page).toHaveURL(/\/groups\/g-user\/personas$/)
|
||||
await expect(page.getByText('Acme decision maker')).toBeVisible()
|
||||
await page.getByRole('button', { name: /แชท|Chat/ }).click()
|
||||
await expect(page).toHaveURL(/\/groups\/g-user\/chat\/p1$/)
|
||||
await expect(page.getByText(/Choose a scenario|เลือกสถานการณ์/)).toBeVisible()
|
||||
await page.getByRole('button', { name: /เริ่มต้น|Start/ }).click()
|
||||
await expect(page.locator('input[placeholder="ส่ง"], input[placeholder="Send"]')).toBeVisible()
|
||||
const widthState = await page.evaluate(() => ({ width: window.innerWidth, scrollWidth: document.documentElement.scrollWidth }))
|
||||
expect(widthState.scrollWidth).toBeLessThanOrEqual(widthState.width)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user