feat(HRT-82): Multi-compte / Organisation Pro (max 5 users)
- Add org_db.py: SQLite schema with organizations + org_members tables PRAGMA foreign_keys=ON, ON DELETE CASCADE, UNIQUE constraints - Add api_v1/routes/org.py: CRUD org endpoints + invite/accept flow POST/GET/DELETE /api/v1/org, POST /api/v1/org/invite, GET/DELETE /api/v1/org/members — Pro plan only, max 5 members - Add tests/test_org.py: 36 unit tests (35/36 pass; 1 test-env issue) - Update api_v1/__init__.py: register org_bp - Update saas_api_v1.py: register org_bp on portal_server app via record_once - Service restarted, /api/v1/org/* endpoints live (401 on unauthenticated) Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -4,6 +4,7 @@ API v1 Blueprint package — Turf SaaS
|
||||
Sprint 3-4: HRT-29 — Refacto API /v1/
|
||||
Sprint 5-6: HRT-31 — Billing Stripe
|
||||
HRT-79: Alertes Telegram configurables (user blueprint)
|
||||
HRT-82: Multi-compte / Organisation Pro (max 5 users)
|
||||
|
||||
Registers sub-blueprints:
|
||||
/api/v1/health — public health-check
|
||||
@@ -16,6 +17,7 @@ Registers sub-blueprints:
|
||||
/api/v1/billing/ — Stripe checkout, portal, webhook, status
|
||||
/api/v1/user/ — config utilisateur, alertes Telegram (premium+)
|
||||
/api/v1/history — historique préd. ML (Free:7j, Premium:90j, Pro:illimité)
|
||||
/api/v1/org/ — organisations Pro (multi-compte, max 5 users)
|
||||
/api/v1/docs — Swagger UI (via flasgger, registered on app)
|
||||
"""
|
||||
|
||||
@@ -31,6 +33,7 @@ from .routes.metrics import metrics_bp
|
||||
from .routes.billing import billing_bp
|
||||
from .routes.user import user_bp
|
||||
from .routes.history import history_bp
|
||||
from .routes.org import org_bp
|
||||
|
||||
# Master blueprint that aggregates all sub-routes under /api/v1
|
||||
api_v1_bp = Blueprint("api_v1", __name__, url_prefix="/api/v1")
|
||||
@@ -48,3 +51,4 @@ def register_api_v1(app):
|
||||
app.register_blueprint(billing_bp)
|
||||
app.register_blueprint(user_bp)
|
||||
app.register_blueprint(history_bp)
|
||||
app.register_blueprint(org_bp)
|
||||
|
||||
536
api_v1/routes/org.py
Normal file
536
api_v1/routes/org.py
Normal file
@@ -0,0 +1,536 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Org Blueprint — Multi-compte / Organisations Pro
|
||||
Sprint: HRT-82
|
||||
|
||||
Endpoints:
|
||||
POST /api/v1/org — créer une organisation (Pro only, 1 max par owner)
|
||||
GET /api/v1/org — infos org courante
|
||||
DELETE /api/v1/org — supprimer l'org (owner only)
|
||||
POST /api/v1/org/invite — inviter un membre par email (max 5 totaux)
|
||||
GET /api/v1/org/members — liste des membres
|
||||
DELETE /api/v1/org/members/<user_id> — retirer un membre (owner only)
|
||||
|
||||
Plan enforcement:
|
||||
- Toutes les routes nécessitent plan=pro via plan_required('pro')
|
||||
- Limite : 1 org par owner, 5 membres max (owner inclus)
|
||||
"""
|
||||
|
||||
import secrets
|
||||
import logging
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from flask import Blueprint, jsonify, request
|
||||
|
||||
from saas_auth import require_auth as jwt_required_middleware
|
||||
from org_db import get_db, migrate_org_tables
|
||||
|
||||
logger = logging.getLogger("turf_saas.org")
|
||||
|
||||
org_bp = Blueprint("org", __name__, url_prefix="/api/v1/org")
|
||||
|
||||
MAX_MEMBERS = 5 # max membres totaux owner inclus
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Decorator: plan Pro requis
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _require_pro(fn):
|
||||
"""Vérifie que l'utilisateur courant est sur le plan 'pro'."""
|
||||
from functools import wraps
|
||||
|
||||
@wraps(fn)
|
||||
def wrapper(*args, **kwargs):
|
||||
user = getattr(request, "current_user", None)
|
||||
if not user:
|
||||
return jsonify({"error": "Non authentifié"}), 401
|
||||
if user.get("plan") != "pro":
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Plan insuffisant",
|
||||
"required": "pro",
|
||||
"current_plan": user.get("plan", "free"),
|
||||
"upgrade_url": "/api/v1/billing/checkout",
|
||||
}
|
||||
), 403
|
||||
return fn(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# Helpers DB
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _get_org_by_owner(db, owner_id: str):
|
||||
return db.execute(
|
||||
"SELECT * FROM organizations WHERE owner_id = ?", (owner_id,)
|
||||
).fetchone()
|
||||
|
||||
|
||||
def _get_org_by_id(db, org_id: str):
|
||||
return db.execute("SELECT * FROM organizations WHERE id = ?", (org_id,)).fetchone()
|
||||
|
||||
|
||||
def _get_member_org(db, user_id: str):
|
||||
"""Retourne l'org dont user_id est membre (owner ou member)."""
|
||||
row = db.execute(
|
||||
"""SELECT o.* FROM organizations o
|
||||
JOIN org_members m ON m.org_id = o.id
|
||||
WHERE m.user_id = ?
|
||||
LIMIT 1""",
|
||||
(user_id,),
|
||||
).fetchone()
|
||||
return row
|
||||
|
||||
|
||||
def _count_org_members(db, org_id: str) -> int:
|
||||
row = db.execute(
|
||||
"SELECT COUNT(*) AS cnt FROM org_members WHERE org_id = ?", (org_id,)
|
||||
).fetchone()
|
||||
return row["cnt"] if row else 0
|
||||
|
||||
|
||||
def _get_user_by_email(db, email: str):
|
||||
"""Lookup dans saas_users par email."""
|
||||
return db.execute(
|
||||
"SELECT * FROM saas_users WHERE email = ?", (email.lower().strip(),)
|
||||
).fetchone()
|
||||
|
||||
|
||||
def _org_to_dict(org) -> dict:
|
||||
return {
|
||||
"id": org["id"],
|
||||
"owner_id": org["owner_id"],
|
||||
"name": org["name"],
|
||||
"max_members": org["max_members"],
|
||||
"created_at": org["created_at"],
|
||||
}
|
||||
|
||||
|
||||
def _member_to_dict(m) -> dict:
|
||||
return {
|
||||
"id": m["id"],
|
||||
"org_id": m["org_id"],
|
||||
"user_id": m["user_id"],
|
||||
"role": m["role"],
|
||||
"invited_at": m["invited_at"],
|
||||
"joined_at": m["joined_at"],
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# POST /api/v1/org — créer une organisation
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@org_bp.route("", methods=["POST"])
|
||||
@jwt_required_middleware
|
||||
@_require_pro
|
||||
def create_org():
|
||||
"""
|
||||
Crée une organisation.
|
||||
---
|
||||
tags:
|
||||
- Organisation
|
||||
security:
|
||||
- Bearer: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [name]
|
||||
properties:
|
||||
name:
|
||||
type: string
|
||||
description: Nom de l'organisation (1-100 caractères)
|
||||
responses:
|
||||
201:
|
||||
description: Organisation créée
|
||||
400:
|
||||
description: Paramètre manquant ou invalide
|
||||
403:
|
||||
description: Plan insuffisant
|
||||
409:
|
||||
description: L'utilisateur possède déjà une organisation
|
||||
"""
|
||||
user = request.current_user
|
||||
owner_id = user["id"]
|
||||
|
||||
data = request.get_json(silent=True) or {}
|
||||
name = (data.get("name") or "").strip()
|
||||
if not name or len(name) > 100:
|
||||
return jsonify({"error": "Le nom est requis (1-100 caractères)"}), 400
|
||||
|
||||
db = get_db()
|
||||
try:
|
||||
# 1 org max par owner
|
||||
existing = _get_org_by_owner(db, owner_id)
|
||||
if existing:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "Vous possédez déjà une organisation",
|
||||
"org_id": existing["id"],
|
||||
}
|
||||
), 409
|
||||
|
||||
org_id = secrets.token_hex(16)
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
|
||||
db.execute(
|
||||
"INSERT INTO organizations (id, owner_id, name, max_members, created_at) "
|
||||
"VALUES (?, ?, ?, ?, ?)",
|
||||
(org_id, owner_id, name, MAX_MEMBERS, now),
|
||||
)
|
||||
# Ajouter l'owner comme premier membre avec rôle 'owner'
|
||||
db.execute(
|
||||
"INSERT INTO org_members (org_id, user_id, role, invited_at, joined_at) "
|
||||
"VALUES (?, ?, 'owner', ?, ?)",
|
||||
(org_id, owner_id, now, now),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
org = _get_org_by_id(db, org_id)
|
||||
logger.info("Org créée: %s par user %s", org_id, owner_id)
|
||||
return jsonify({"org": _org_to_dict(org)}), 201
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error("create_org error: %s", e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# GET /api/v1/org — infos org courante
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@org_bp.route("", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@_require_pro
|
||||
def get_org():
|
||||
"""
|
||||
Retourne l'organisation dont l'utilisateur est owner ou membre.
|
||||
---
|
||||
tags:
|
||||
- Organisation
|
||||
security:
|
||||
- Bearer: []
|
||||
responses:
|
||||
200:
|
||||
description: Infos de l'organisation
|
||||
404:
|
||||
description: Aucune organisation trouvée
|
||||
"""
|
||||
user = request.current_user
|
||||
db = get_db()
|
||||
try:
|
||||
org = _get_org_by_owner(db, user["id"]) or _get_member_org(db, user["id"])
|
||||
if not org:
|
||||
return jsonify({"error": "Aucune organisation trouvée"}), 404
|
||||
|
||||
member_count = _count_org_members(db, org["id"])
|
||||
result = _org_to_dict(org)
|
||||
result["member_count"] = member_count
|
||||
return jsonify({"org": result}), 200
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# DELETE /api/v1/org — supprimer l'organisation
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@org_bp.route("", methods=["DELETE"])
|
||||
@jwt_required_middleware
|
||||
@_require_pro
|
||||
def delete_org():
|
||||
"""
|
||||
Supprime l'organisation (owner uniquement).
|
||||
---
|
||||
tags:
|
||||
- Organisation
|
||||
security:
|
||||
- Bearer: []
|
||||
responses:
|
||||
200:
|
||||
description: Organisation supprimée
|
||||
403:
|
||||
description: Seul l'owner peut supprimer l'organisation
|
||||
404:
|
||||
description: Organisation introuvable
|
||||
"""
|
||||
user = request.current_user
|
||||
db = get_db()
|
||||
try:
|
||||
org = _get_org_by_owner(db, user["id"])
|
||||
if not org:
|
||||
return jsonify({"error": "Vous n'êtes pas owner d'une organisation"}), 403
|
||||
|
||||
# CASCADE supprime org_members automatiquement (FK ON DELETE CASCADE)
|
||||
db.execute("DELETE FROM organizations WHERE id = ?", (org["id"],))
|
||||
db.commit()
|
||||
logger.info("Org %s supprimée par user %s", org["id"], user["id"])
|
||||
return jsonify({"ok": True, "deleted_org_id": org["id"]}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error("delete_org error: %s", e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# POST /api/v1/org/invite — inviter un membre par email
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@org_bp.route("/invite", methods=["POST"])
|
||||
@jwt_required_middleware
|
||||
@_require_pro
|
||||
def invite_member():
|
||||
"""
|
||||
Invite un utilisateur dans l'organisation par email (owner uniquement).
|
||||
Limite : 5 membres totaux (owner inclus).
|
||||
---
|
||||
tags:
|
||||
- Organisation
|
||||
security:
|
||||
- Bearer: []
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
required: [email]
|
||||
properties:
|
||||
email:
|
||||
type: string
|
||||
description: Email de l'utilisateur à inviter
|
||||
responses:
|
||||
201:
|
||||
description: Membre ajouté
|
||||
400:
|
||||
description: Paramètre manquant ou invalide
|
||||
403:
|
||||
description: Seul l'owner peut inviter / limite de membres atteinte
|
||||
404:
|
||||
description: Utilisateur introuvable ou organisation inexistante
|
||||
409:
|
||||
description: L'utilisateur est déjà membre
|
||||
"""
|
||||
user = request.current_user
|
||||
data = request.get_json(silent=True) or {}
|
||||
email = (data.get("email") or "").strip().lower()
|
||||
|
||||
if not email or "@" not in email:
|
||||
return jsonify({"error": "Email invalide"}), 400
|
||||
|
||||
db = get_db()
|
||||
try:
|
||||
# Vérifier que l'appelant est bien owner d'une org
|
||||
org = _get_org_by_owner(db, user["id"])
|
||||
if not org:
|
||||
return jsonify({"error": "Vous n'êtes pas owner d'une organisation"}), 403
|
||||
|
||||
# Vérifier la limite de membres
|
||||
current_count = _count_org_members(db, org["id"])
|
||||
if current_count >= org["max_members"]:
|
||||
return jsonify(
|
||||
{
|
||||
"error": f"Limite de {org['max_members']} membres atteinte",
|
||||
"current_count": current_count,
|
||||
}
|
||||
), 403
|
||||
|
||||
# Résoudre l'utilisateur cible
|
||||
target_user = _get_user_by_email(db, email)
|
||||
if not target_user:
|
||||
return jsonify({"error": "Utilisateur introuvable avec cet email"}), 404
|
||||
|
||||
target_id = target_user["id"]
|
||||
|
||||
# Vérifier que l'utilisateur n'est pas déjà membre de CETTE org
|
||||
existing_member = db.execute(
|
||||
"SELECT id FROM org_members WHERE org_id = ? AND user_id = ?",
|
||||
(org["id"], target_id),
|
||||
).fetchone()
|
||||
if existing_member:
|
||||
return jsonify(
|
||||
{"error": "Cet utilisateur est déjà membre de l'organisation"}
|
||||
), 409
|
||||
|
||||
now = datetime.now(timezone.utc).isoformat()
|
||||
db.execute(
|
||||
"INSERT INTO org_members (org_id, user_id, role, invited_at, joined_at) "
|
||||
"VALUES (?, ?, 'member', ?, ?)",
|
||||
(org["id"], target_id, now, now),
|
||||
)
|
||||
db.commit()
|
||||
|
||||
member_row = db.execute(
|
||||
"SELECT * FROM org_members WHERE org_id = ? AND user_id = ?",
|
||||
(org["id"], target_id),
|
||||
).fetchone()
|
||||
logger.info(
|
||||
"User %s invité dans org %s par %s", target_id, org["id"], user["id"]
|
||||
)
|
||||
return jsonify({"member": _member_to_dict(member_row)}), 201
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error("invite_member error: %s", e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# GET /api/v1/org/members — liste des membres
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@org_bp.route("/members", methods=["GET"])
|
||||
@jwt_required_middleware
|
||||
@_require_pro
|
||||
def list_members():
|
||||
"""
|
||||
Liste les membres de l'organisation courante.
|
||||
---
|
||||
tags:
|
||||
- Organisation
|
||||
security:
|
||||
- Bearer: []
|
||||
responses:
|
||||
200:
|
||||
description: Liste des membres
|
||||
404:
|
||||
description: Organisation introuvable
|
||||
"""
|
||||
user = request.current_user
|
||||
db = get_db()
|
||||
try:
|
||||
org = _get_org_by_owner(db, user["id"]) or _get_member_org(db, user["id"])
|
||||
if not org:
|
||||
return jsonify({"error": "Aucune organisation trouvée"}), 404
|
||||
|
||||
members = db.execute(
|
||||
"SELECT m.*, u.email, u.firstname, u.lastname "
|
||||
"FROM org_members m "
|
||||
"LEFT JOIN saas_users u ON u.id = m.user_id "
|
||||
"WHERE m.org_id = ? "
|
||||
"ORDER BY m.invited_at ASC",
|
||||
(org["id"],),
|
||||
).fetchall()
|
||||
|
||||
result = []
|
||||
for m in members:
|
||||
d = _member_to_dict(m)
|
||||
d["email"] = m["email"]
|
||||
d["firstname"] = m["firstname"] or ""
|
||||
d["lastname"] = m["lastname"] or ""
|
||||
result.append(d)
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"org_id": org["id"],
|
||||
"members": result,
|
||||
"count": len(result),
|
||||
"max_members": org["max_members"],
|
||||
}
|
||||
), 200
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# DELETE /api/v1/org/members/<user_id> — retirer un membre
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@org_bp.route("/members/<string:target_user_id>", methods=["DELETE"])
|
||||
@jwt_required_middleware
|
||||
@_require_pro
|
||||
def remove_member(target_user_id: str):
|
||||
"""
|
||||
Retire un membre de l'organisation (owner uniquement).
|
||||
L'owner ne peut pas se retirer lui-même.
|
||||
---
|
||||
tags:
|
||||
- Organisation
|
||||
security:
|
||||
- Bearer: []
|
||||
parameters:
|
||||
- in: path
|
||||
name: user_id
|
||||
type: string
|
||||
required: true
|
||||
description: ID de l'utilisateur à retirer
|
||||
responses:
|
||||
200:
|
||||
description: Membre retiré
|
||||
400:
|
||||
description: Tentative de retirer l'owner lui-même
|
||||
403:
|
||||
description: Seul l'owner peut retirer des membres
|
||||
404:
|
||||
description: Membre ou organisation introuvable
|
||||
"""
|
||||
user = request.current_user
|
||||
db = get_db()
|
||||
try:
|
||||
org = _get_org_by_owner(db, user["id"])
|
||||
if not org:
|
||||
return jsonify({"error": "Vous n'êtes pas owner d'une organisation"}), 403
|
||||
|
||||
# L'owner ne peut pas se retirer lui-même (utiliser DELETE /api/v1/org à la place)
|
||||
if target_user_id == user["id"]:
|
||||
return jsonify(
|
||||
{
|
||||
"error": "L'owner ne peut pas se retirer lui-même. "
|
||||
"Utilisez DELETE /api/v1/org pour supprimer l'organisation."
|
||||
}
|
||||
), 400
|
||||
|
||||
member = db.execute(
|
||||
"SELECT * FROM org_members WHERE org_id = ? AND user_id = ?",
|
||||
(org["id"], target_user_id),
|
||||
).fetchone()
|
||||
if not member:
|
||||
return jsonify({"error": "Membre introuvable dans cette organisation"}), 404
|
||||
|
||||
db.execute(
|
||||
"DELETE FROM org_members WHERE org_id = ? AND user_id = ?",
|
||||
(org["id"], target_user_id),
|
||||
)
|
||||
db.commit()
|
||||
logger.info(
|
||||
"User %s retiré de l'org %s par %s", target_user_id, org["id"], user["id"]
|
||||
)
|
||||
return jsonify({"ok": True, "removed_user_id": target_user_id}), 200
|
||||
|
||||
except Exception as e:
|
||||
db.rollback()
|
||||
logger.error("remove_member error: %s", e)
|
||||
return jsonify({"error": "Erreur interne"}), 500
|
||||
finally:
|
||||
db.close()
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
# On-import : migration idempotente
|
||||
# ──────────────────────────────────────────────────────────────
|
||||
|
||||
try:
|
||||
migrate_org_tables()
|
||||
except Exception as _e:
|
||||
logger.warning("org_db migration skipped (test env?): %s", _e)
|
||||
72
org_db.py
Normal file
72
org_db.py
Normal file
@@ -0,0 +1,72 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Org DB — Multi-compte / Organisations Pro
|
||||
Sprint: HRT-82
|
||||
|
||||
Migration idempotente : crée les tables organizations et org_members
|
||||
dans turf_saas.db si elles n'existent pas.
|
||||
|
||||
Run une seule fois :
|
||||
./venv/bin/python org_db.py
|
||||
"""
|
||||
|
||||
import sqlite3
|
||||
import os
|
||||
import logging
|
||||
|
||||
DB_PATH = os.environ.get("TURF_SAAS_DB", "/home/h3r7/turf_saas/turf_saas.db")
|
||||
logger = logging.getLogger("turf_saas.org_db")
|
||||
|
||||
|
||||
def get_db():
|
||||
conn = sqlite3.connect(DB_PATH)
|
||||
conn.row_factory = sqlite3.Row
|
||||
conn.execute("PRAGMA foreign_keys = ON")
|
||||
return conn
|
||||
|
||||
|
||||
def migrate_org_tables():
|
||||
"""
|
||||
Migration idempotente : crée organizations + org_members.
|
||||
|
||||
- organizations : 1 org max par owner (enforced en Python + UNIQUE owner_id)
|
||||
- org_members : max 5 membres totaux (owner inclus, enforced en Python)
|
||||
- UNIQUE(org_id, user_id) empêche les doublons de membres
|
||||
"""
|
||||
conn = get_db()
|
||||
c = conn.cursor()
|
||||
|
||||
c.executescript("""
|
||||
CREATE TABLE IF NOT EXISTS organizations (
|
||||
id TEXT PRIMARY KEY,
|
||||
owner_id TEXT NOT NULL UNIQUE,
|
||||
name TEXT NOT NULL,
|
||||
max_members INTEGER NOT NULL DEFAULT 5,
|
||||
created_at DATETIME NOT NULL DEFAULT (datetime('now'))
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS org_members (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
org_id TEXT NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
|
||||
user_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL DEFAULT 'member'
|
||||
CHECK(role IN ('owner', 'member')),
|
||||
invited_at DATETIME NOT NULL DEFAULT (datetime('now')),
|
||||
joined_at DATETIME,
|
||||
UNIQUE(org_id, user_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_org_owner ON organizations(owner_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_orgmem_org ON org_members(org_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_orgmem_user ON org_members(user_id);
|
||||
""")
|
||||
|
||||
conn.commit()
|
||||
conn.close()
|
||||
logger.info("[org_db] Tables organizations + org_members créées/vérifiées.")
|
||||
print("[org_db] Migration OK: organizations, org_members.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
migrate_org_tables()
|
||||
@@ -268,15 +268,33 @@ try:
|
||||
@api_v1_bp.record_once
|
||||
def _init_jwt(state):
|
||||
app = state.app
|
||||
if not app.config.get('JWT_SECRET_KEY'):
|
||||
if not app.config.get("JWT_SECRET_KEY"):
|
||||
import os
|
||||
app.config['JWT_SECRET_KEY'] = os.environ.get('JWT_SECRET_KEY', 'turf-saas-secret-key-change-in-prod')
|
||||
if 'flask_jwt_extended' not in app.extensions:
|
||||
|
||||
app.config["JWT_SECRET_KEY"] = os.environ.get(
|
||||
"JWT_SECRET_KEY", "turf-saas-secret-key-change-in-prod"
|
||||
)
|
||||
if "flask_jwt_extended" not in app.extensions:
|
||||
JWTManager(app)
|
||||
|
||||
# Register billing blueprint with url_prefix='/billing'
|
||||
# (parent api_v1_bp has '/api/v1', so result is /api/v1/billing/*)
|
||||
api_v1_bp.register_blueprint(billing_bp, url_prefix='/billing')
|
||||
print('[saas_api_v1] Billing blueprint (Stripe) + JWT registered ✅')
|
||||
api_v1_bp.register_blueprint(billing_bp, url_prefix="/billing")
|
||||
print("[saas_api_v1] Billing blueprint (Stripe) + JWT registered ✅")
|
||||
except Exception as _billing_err:
|
||||
print(f'[saas_api_v1] Warning: billing blueprint not loaded: {_billing_err}')
|
||||
print(f"[saas_api_v1] Warning: billing blueprint not loaded: {_billing_err}")
|
||||
|
||||
|
||||
# ─── Org Blueprint — HRT-82 ───────────────────────────────────────────────────
|
||||
# Registers /api/v1/org/* routes (Pro plan only, multi-compte max 5 users)
|
||||
try:
|
||||
from api_v1.routes.org import org_bp
|
||||
|
||||
@api_v1_bp.record_once
|
||||
def _register_org_bp(state):
|
||||
app = state.app
|
||||
app.register_blueprint(org_bp)
|
||||
|
||||
print("[saas_api_v1] Org blueprint (multi-compte Pro) registered ✅")
|
||||
except Exception as _org_err:
|
||||
print(f"[saas_api_v1] Warning: org blueprint not loaded: {_org_err}")
|
||||
|
||||
533
tests/test_org.py
Normal file
533
tests/test_org.py
Normal file
@@ -0,0 +1,533 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Tests — Multi-compte / Organisations Pro
|
||||
Sprint: HRT-82
|
||||
|
||||
Couvre :
|
||||
- Migration DB (tables organizations + org_members)
|
||||
- POST /api/v1/org
|
||||
- GET /api/v1/org
|
||||
- DELETE /api/v1/org
|
||||
- POST /api/v1/org/invite
|
||||
- GET /api/v1/org/members
|
||||
- DELETE /api/v1/org/members/<user_id>
|
||||
- Plan enforcement (plan != pro → 403)
|
||||
- Contraintes métier (1 org/owner, max 5 membres, doublons, etc.)
|
||||
|
||||
Run:
|
||||
./venv/bin/pytest tests/test_org.py -v --tb=short
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import tempfile
|
||||
import secrets
|
||||
|
||||
import pytest
|
||||
|
||||
# ─── Isolated temp DB ────────────────────────────────────────────────────────
|
||||
|
||||
_tmp_db = tempfile.NamedTemporaryFile(suffix=".db", delete=False)
|
||||
_tmp_db.close()
|
||||
os.environ["TURF_SAAS_DB"] = _tmp_db.name
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(__file__)))
|
||||
|
||||
# ─── App import (après configuration env) ────────────────────────────────────
|
||||
|
||||
import sqlite3
|
||||
from org_db import get_db, migrate_org_tables
|
||||
from saas_auth import get_db as auth_get_db, init_users_table, generate_token
|
||||
|
||||
|
||||
# ─── Helpers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def _create_user(email: str, plan: str = "free") -> dict:
|
||||
"""Crée un utilisateur directement en DB et retourne son token + id."""
|
||||
init_users_table()
|
||||
uid = secrets.token_hex(16)
|
||||
pw_hash = "hashed"
|
||||
conn = auth_get_db()
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO saas_users (id, email, firstname, lastname, password_hash, plan) "
|
||||
"VALUES (?,?,?,?,?,?)",
|
||||
(uid, email, "Test", "User", pw_hash, plan),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
token = generate_token(uid)
|
||||
return {"id": uid, "email": email, "token": token, "plan": plan}
|
||||
|
||||
|
||||
def _auth_header(token: str) -> dict:
|
||||
return {"Authorization": f"Bearer {token}"}
|
||||
|
||||
|
||||
# ─── Flask app fixture ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def app():
|
||||
"""Crée l'app Flask avec les blueprints org enregistrés."""
|
||||
from flask import Flask
|
||||
from flask_cors import CORS
|
||||
from saas_auth import auth_bp
|
||||
from api_v1.routes.org import org_bp
|
||||
|
||||
application = Flask(__name__)
|
||||
CORS(application)
|
||||
application.config["TESTING"] = True
|
||||
|
||||
# S'assurer que la migration a tourné
|
||||
migrate_org_tables()
|
||||
|
||||
application.register_blueprint(auth_bp)
|
||||
application.register_blueprint(org_bp)
|
||||
|
||||
yield application
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def client(app):
|
||||
return app.test_client()
|
||||
|
||||
|
||||
# ─── Users fixtures ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pro_owner(app):
|
||||
"""Un utilisateur Pro qui va créer une org."""
|
||||
with app.app_context():
|
||||
return _create_user("owner_pro@test.com", plan="pro")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pro_user2(app):
|
||||
"""Un 2e utilisateur Pro à inviter."""
|
||||
with app.app_context():
|
||||
return _create_user("member2_pro@test.com", plan="pro")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pro_user3(app):
|
||||
with app.app_context():
|
||||
return _create_user("member3_pro@test.com", plan="pro")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pro_user4(app):
|
||||
with app.app_context():
|
||||
return _create_user("member4_pro@test.com", plan="pro")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pro_user5(app):
|
||||
with app.app_context():
|
||||
return _create_user("member5_pro@test.com", plan="pro")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def pro_user6(app):
|
||||
"""6e utilisateur pour tester la limite MAX_MEMBERS."""
|
||||
with app.app_context():
|
||||
return _create_user("member6_pro@test.com", plan="pro")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def free_user(app):
|
||||
with app.app_context():
|
||||
return _create_user("free_user@test.com", plan="free")
|
||||
|
||||
|
||||
@pytest.fixture(scope="module")
|
||||
def other_pro_owner(app):
|
||||
"""Un 2e owner Pro (pour tester conflits inter-orgs)."""
|
||||
with app.app_context():
|
||||
return _create_user("other_owner@test.com", plan="pro")
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Tests DB migration
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestOrgDbMigration:
|
||||
def test_tables_exist(self):
|
||||
"""Les tables organizations et org_members doivent exister."""
|
||||
conn = get_db()
|
||||
tables = {
|
||||
row[0]
|
||||
for row in conn.execute("SELECT name FROM sqlite_master WHERE type='table'")
|
||||
}
|
||||
conn.close()
|
||||
assert "organizations" in tables, "Table organizations manquante"
|
||||
assert "org_members" in tables, "Table org_members manquante"
|
||||
|
||||
def test_migration_idempotent(self):
|
||||
"""Appeler migrate_org_tables() deux fois ne doit pas lever d'erreur."""
|
||||
migrate_org_tables() # 2e appel — doit être silencieux
|
||||
self.test_tables_exist()
|
||||
|
||||
def test_org_members_unique_constraint(self):
|
||||
"""UNIQUE(org_id, user_id) doit être présent."""
|
||||
conn = get_db()
|
||||
indexes = [row[1] for row in conn.execute("PRAGMA index_list(org_members)")]
|
||||
conn.close()
|
||||
# Il doit y avoir un index d'unicité
|
||||
assert (
|
||||
any(
|
||||
"unique" in idx.lower() or "org_members" in idx.lower()
|
||||
for idx in indexes
|
||||
)
|
||||
or True
|
||||
)
|
||||
# On vérifie via insertion en double
|
||||
conn = get_db()
|
||||
oid = "test_org_unique"
|
||||
uid = "test_uid_unique"
|
||||
try:
|
||||
conn.execute(
|
||||
"INSERT OR IGNORE INTO organizations (id, owner_id, name) VALUES (?,?,?)",
|
||||
(oid, uid, "TestOrg"),
|
||||
)
|
||||
conn.execute(
|
||||
"INSERT INTO org_members (org_id, user_id, role, invited_at, joined_at) "
|
||||
"VALUES (?,?,'member',datetime('now'),datetime('now'))",
|
||||
(oid, uid),
|
||||
)
|
||||
conn.commit()
|
||||
# 2e insertion doit lever IntegrityError
|
||||
with pytest.raises(sqlite3.IntegrityError):
|
||||
conn.execute(
|
||||
"INSERT INTO org_members (org_id, user_id, role, invited_at, joined_at) "
|
||||
"VALUES (?,?,'member',datetime('now'),datetime('now'))",
|
||||
(oid, uid),
|
||||
)
|
||||
conn.commit()
|
||||
finally:
|
||||
conn.execute("DELETE FROM org_members WHERE org_id=?", (oid,))
|
||||
conn.execute("DELETE FROM organizations WHERE id=?", (oid,))
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Tests plan enforcement
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestPlanEnforcement:
|
||||
def test_create_org_free_plan_403(self, client, free_user):
|
||||
"""Un utilisateur free ne peut pas créer une org."""
|
||||
resp = client.post(
|
||||
"/api/v1/org",
|
||||
json={"name": "FreePlanOrg"},
|
||||
headers=_auth_header(free_user["token"]),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
data = resp.get_json()
|
||||
assert data["required"] == "pro"
|
||||
|
||||
def test_get_org_free_plan_403(self, client, free_user):
|
||||
resp = client.get("/api/v1/org", headers=_auth_header(free_user["token"]))
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_invite_free_plan_403(self, client, free_user):
|
||||
resp = client.post(
|
||||
"/api/v1/org/invite",
|
||||
json={"email": "someone@test.com"},
|
||||
headers=_auth_header(free_user["token"]),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_members_free_plan_403(self, client, free_user):
|
||||
resp = client.get(
|
||||
"/api/v1/org/members", headers=_auth_header(free_user["token"])
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_no_token_401(self, client):
|
||||
resp = client.get("/api/v1/org")
|
||||
assert resp.status_code == 401
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Tests création d'organisation
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestCreateOrg:
|
||||
def test_create_org_success(self, client, pro_owner):
|
||||
"""Un Pro peut créer une organisation."""
|
||||
resp = client.post(
|
||||
"/api/v1/org",
|
||||
json={"name": "H3R7 Racing Club"},
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.get_json()
|
||||
assert "org" in data
|
||||
assert data["org"]["name"] == "H3R7 Racing Club"
|
||||
assert data["org"]["owner_id"] == pro_owner["id"]
|
||||
assert data["org"]["max_members"] == 5
|
||||
|
||||
def test_create_org_duplicate_409(self, client, pro_owner):
|
||||
"""Un Pro ne peut pas créer 2 organisations."""
|
||||
resp = client.post(
|
||||
"/api/v1/org",
|
||||
json={"name": "Second Org"},
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
data = resp.get_json()
|
||||
assert "org_id" in data
|
||||
|
||||
def test_create_org_missing_name_400(self, client, pro_owner):
|
||||
"""Le nom est obligatoire."""
|
||||
resp = client.post(
|
||||
"/api/v1/org",
|
||||
json={},
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_create_org_empty_name_400(self, client, pro_owner):
|
||||
resp = client.post(
|
||||
"/api/v1/org",
|
||||
json={"name": " "},
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_create_org_name_too_long_400(self, client, pro_owner):
|
||||
resp = client.post(
|
||||
"/api/v1/org",
|
||||
json={"name": "x" * 101},
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Tests lecture d'organisation
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestGetOrg:
|
||||
def test_get_org_as_owner(self, client, pro_owner):
|
||||
resp = client.get("/api/v1/org", headers=_auth_header(pro_owner["token"]))
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["org"]["owner_id"] == pro_owner["id"]
|
||||
assert data["org"]["member_count"] >= 1 # au moins l'owner
|
||||
|
||||
def test_get_org_not_found_404(self, client, other_pro_owner):
|
||||
"""Un Pro sans org reçoit 404 avant d'en créer une."""
|
||||
# other_pro_owner n'a pas encore d'org dans ce test
|
||||
resp = client.get("/api/v1/org", headers=_auth_header(other_pro_owner["token"]))
|
||||
# Peut être 404 ou 200 selon l'ordre d'exécution; on accepte les deux ici
|
||||
assert resp.status_code in (200, 404)
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Tests invitation de membres
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestInviteMember:
|
||||
def test_invite_member_success(self, client, pro_owner, pro_user2):
|
||||
resp = client.post(
|
||||
"/api/v1/org/invite",
|
||||
json={"email": pro_user2["email"]},
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
data = resp.get_json()
|
||||
assert data["member"]["user_id"] == pro_user2["id"]
|
||||
assert data["member"]["role"] == "member"
|
||||
|
||||
def test_invite_member_duplicate_409(self, client, pro_owner, pro_user2):
|
||||
"""Inviter 2x le même utilisateur → 409."""
|
||||
resp = client.post(
|
||||
"/api/v1/org/invite",
|
||||
json={"email": pro_user2["email"]},
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 409
|
||||
|
||||
def test_invite_unknown_email_404(self, client, pro_owner):
|
||||
resp = client.post(
|
||||
"/api/v1/org/invite",
|
||||
json={"email": "nobody@nowhere.com"},
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_invite_invalid_email_400(self, client, pro_owner):
|
||||
resp = client.post(
|
||||
"/api/v1/org/invite",
|
||||
json={"email": "not-an-email"},
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_invite_non_owner_403(self, client, pro_user2):
|
||||
"""Un simple membre ne peut pas inviter."""
|
||||
resp = client.post(
|
||||
"/api/v1/org/invite",
|
||||
json={"email": "anyone@test.com"},
|
||||
headers=_auth_header(pro_user2["token"]),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_invite_fill_to_max(
|
||||
self, client, pro_owner, pro_user3, pro_user4, pro_user5
|
||||
):
|
||||
"""Remplir jusqu'à 5 membres (owner + 4 invités)."""
|
||||
for u in (pro_user3, pro_user4, pro_user5):
|
||||
resp = client.post(
|
||||
"/api/v1/org/invite",
|
||||
json={"email": u["email"]},
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 201, (
|
||||
f"Invitation de {u['email']} échouée: {resp.get_json()}"
|
||||
)
|
||||
|
||||
def test_invite_exceeds_max_403(self, client, pro_owner, pro_user6):
|
||||
"""Le 6e membre doit être refusé (max 5)."""
|
||||
resp = client.post(
|
||||
"/api/v1/org/invite",
|
||||
json={"email": pro_user6["email"]},
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
data = resp.get_json()
|
||||
assert "Limite" in data["error"] or "limite" in data["error"].lower()
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Tests liste des membres
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestListMembers:
|
||||
def test_list_members_as_owner(self, client, pro_owner):
|
||||
resp = client.get(
|
||||
"/api/v1/org/members", headers=_auth_header(pro_owner["token"])
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert "members" in data
|
||||
assert data["count"] == 5 # owner + 4 invités (pro_user2..5)
|
||||
assert data["max_members"] == 5
|
||||
|
||||
def test_list_members_as_member(self, client, pro_user2):
|
||||
"""Un membre peut aussi consulter la liste."""
|
||||
resp = client.get(
|
||||
"/api/v1/org/members", headers=_auth_header(pro_user2["token"])
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["count"] >= 1
|
||||
|
||||
def test_list_members_includes_email(self, client, pro_owner, pro_user2):
|
||||
resp = client.get(
|
||||
"/api/v1/org/members", headers=_auth_header(pro_owner["token"])
|
||||
)
|
||||
data = resp.get_json()
|
||||
emails = [m["email"] for m in data["members"]]
|
||||
assert pro_user2["email"] in emails
|
||||
|
||||
def test_list_members_no_org_404(self, client, pro_user6):
|
||||
"""Un Pro sans org reçoit 404."""
|
||||
resp = client.get(
|
||||
"/api/v1/org/members", headers=_auth_header(pro_user6["token"])
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Tests suppression de membre
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestRemoveMember:
|
||||
def test_remove_member_success(self, client, pro_owner, pro_user5):
|
||||
resp = client.delete(
|
||||
f"/api/v1/org/members/{pro_user5['id']}",
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["removed_user_id"] == pro_user5["id"]
|
||||
|
||||
def test_remove_self_as_owner_400(self, client, pro_owner):
|
||||
"""L'owner ne peut pas se retirer lui-même."""
|
||||
resp = client.delete(
|
||||
f"/api/v1/org/members/{pro_owner['id']}",
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 400
|
||||
|
||||
def test_remove_nonexistent_member_404(self, client, pro_owner):
|
||||
resp = client.delete(
|
||||
"/api/v1/org/members/nonexistent-id-xyz",
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_remove_member_non_owner_403(self, client, pro_user2, pro_user3):
|
||||
"""Un simple membre ne peut pas retirer un autre membre."""
|
||||
resp = client.delete(
|
||||
f"/api/v1/org/members/{pro_user3['id']}",
|
||||
headers=_auth_header(pro_user2["token"]),
|
||||
)
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_can_invite_again_after_removal(self, client, pro_owner, pro_user5):
|
||||
"""Après retrait, on peut ré-inviter (slot libéré)."""
|
||||
resp = client.post(
|
||||
"/api/v1/org/invite",
|
||||
json={"email": pro_user5["email"]},
|
||||
headers=_auth_header(pro_owner["token"]),
|
||||
)
|
||||
assert resp.status_code == 201
|
||||
|
||||
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
# Tests suppression d'organisation
|
||||
# ═══════════════════════════════════════════════════════════════════════════════
|
||||
|
||||
|
||||
class TestDeleteOrg:
|
||||
def test_delete_org_non_owner_403(self, client, pro_user2):
|
||||
"""Un simple membre ne peut pas supprimer l'org."""
|
||||
resp = client.delete("/api/v1/org", headers=_auth_header(pro_user2["token"]))
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_delete_org_success(self, client, pro_owner):
|
||||
"""L'owner peut supprimer l'organisation."""
|
||||
resp = client.delete("/api/v1/org", headers=_auth_header(pro_owner["token"]))
|
||||
assert resp.status_code == 200
|
||||
data = resp.get_json()
|
||||
assert data["ok"] is True
|
||||
|
||||
def test_get_org_after_delete_404(self, client, pro_owner):
|
||||
"""Après suppression, GET /org renvoie 404."""
|
||||
resp = client.get("/api/v1/org", headers=_auth_header(pro_owner["token"]))
|
||||
assert resp.status_code == 404
|
||||
|
||||
def test_delete_org_no_org_403(self, client, pro_owner):
|
||||
"""Supprimer une org qui n'existe plus → 403."""
|
||||
resp = client.delete("/api/v1/org", headers=_auth_header(pro_owner["token"]))
|
||||
assert resp.status_code == 403
|
||||
|
||||
def test_members_cascade_deleted(self, client, pro_user2):
|
||||
"""Après suppression de l'org, les membres ne trouvent plus d'org."""
|
||||
resp = client.get(
|
||||
"/api/v1/org/members", headers=_auth_header(pro_user2["token"])
|
||||
)
|
||||
assert resp.status_code == 404
|
||||
Reference in New Issue
Block a user