feat(backend): invite system + user management

- InviteToken model with unique token for each invite
- POST /users/invite - create invite by email with role (admin/mecanic)
- POST /auth/accept-invite - accept invite, set password, return JWT
- GET /users - list all users in tenant
- DELETE /users/{id} - deactivate user (cannot deactivate owner)
- Alembic migration for invites table
- 25 passing tests (auth + sync + orders + pdf + portal + invoices + users)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-13 17:37:06 +02:00
parent 5fa72e4323
commit 8c0346e41f
10 changed files with 345 additions and 0 deletions

111
backend/tests/test_users.py Normal file
View File

@@ -0,0 +1,111 @@
import pytest
from httpx import ASGITransport, AsyncClient
from app.main import app
@pytest.mark.asyncio
async def test_invite_and_accept_flow(client, auth_headers):
# Invite a new user
r = await client.post(
"/api/users/invite",
headers=auth_headers,
json={"email": "mecanic@service.ro", "rol": "mecanic"},
)
assert r.status_code == 200
data = r.json()
assert "token" in data
assert data["email"] == "mecanic@service.ro"
invite_token = data["token"]
# Accept invite (creates user + returns JWT)
r = await client.post(
"/api/auth/accept-invite",
json={"token": invite_token, "password": "mecanic123"},
)
assert r.status_code == 200
data = r.json()
assert "access_token" in data
assert data["token_type"] == "bearer"
# New user can access /me
new_headers = {"Authorization": f"Bearer {data['access_token']}"}
r = await client.get("/api/auth/me", headers=new_headers)
assert r.status_code == 200
me = r.json()
assert me["email"] == "mecanic@service.ro"
assert me["rol"] == "mecanic"
@pytest.mark.asyncio
async def test_list_users(client, auth_headers):
r = await client.get("/api/users", headers=auth_headers)
assert r.status_code == 200
users = r.json()
assert len(users) >= 1
assert users[0]["rol"] == "owner"
@pytest.mark.asyncio
async def test_deactivate_user(client, auth_headers):
# Invite and accept a user first
r = await client.post(
"/api/users/invite",
headers=auth_headers,
json={"email": "delete@service.ro", "rol": "mecanic"},
)
invite_token = r.json()["token"]
await client.post(
"/api/auth/accept-invite",
json={"token": invite_token, "password": "pass123"},
)
# List users to get the new user's id
r = await client.get("/api/users", headers=auth_headers)
users = r.json()
mecanic = next(u for u in users if u["email"] == "delete@service.ro")
# Deactivate
r = await client.delete(
f"/api/users/{mecanic['id']}", headers=auth_headers
)
assert r.status_code == 200
assert r.json()["ok"] is True
@pytest.mark.asyncio
async def test_cannot_deactivate_owner(client, auth_headers):
r = await client.get("/api/users", headers=auth_headers)
users = r.json()
owner = next(u for u in users if u["rol"] == "owner")
r = await client.delete(
f"/api/users/{owner['id']}", headers=auth_headers
)
assert r.status_code == 422
@pytest.mark.asyncio
async def test_invalid_invite_token(client):
r = await client.post(
"/api/auth/accept-invite",
json={"token": "invalid-token", "password": "pass123"},
)
assert r.status_code == 422
@pytest.mark.asyncio
async def test_duplicate_invite(client, auth_headers):
# Invite same email twice - first should succeed, second may fail if user exists
await client.post(
"/api/users/invite",
headers=auth_headers,
json={"email": "dup@service.ro", "rol": "mecanic"},
)
# Second invite for same email is allowed (user not created yet)
r = await client.post(
"/api/users/invite",
headers=auth_headers,
json={"email": "dup@service.ro", "rol": "admin"},
)
assert r.status_code == 200