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:
Macky
2026-08-25 06:39:06 +07:00
parent 8a632b5e6a
commit 3c22d88bcd
66 changed files with 8531 additions and 733 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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).

View File

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

View File

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

View File

@@ -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})

View File

@@ -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)]

View File

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

View File

@@ -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 {}

View File

@@ -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"],
}
)

View File

@@ -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

View File

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

View File

@@ -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:

View 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__)

View File

@@ -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:

View File

@@ -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,

View File

@@ -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:

View File

@@ -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

View File

@@ -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")
]

View File

@@ -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

View File

@@ -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

View File

@@ -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]:

View File

@@ -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

View File

@@ -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
):

View File

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

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

View File

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

View File

@@ -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),

View File

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

View File

@@ -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

View File

@@ -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),

View File

@@ -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",

View File

@@ -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")))

View File

@@ -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

View File

@@ -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"] == []

View File

@@ -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"}])

View File

@@ -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"]
}

View 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")
)

File diff suppressed because it is too large Load Diff

View File

@@ -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={

View 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

View File

@@ -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")

View File

@@ -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

View File

@@ -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.

View 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.

View 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.

View 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.

View File

@@ -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`),

View 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',
}))
})
})

View File

@@ -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 เผยแพร่กลุ่มก่อน',
},
}

View File

@@ -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')
},
)
})

View File

@@ -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
})

View File

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

View 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)
})
})

View File

@@ -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>

View 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)
})
})

View File

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

View File

@@ -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 {

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

View File

@@ -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>

View File

@@ -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;

View File

@@ -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>

View File

@@ -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 = ''

View 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)
},
)
})

View File

@@ -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>

View File

@@ -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)
})