diff --git a/backend/src/cyclone/auth/__init__.py b/backend/src/cyclone/auth/__init__.py new file mode 100644 index 0000000..31402c2 --- /dev/null +++ b/backend/src/cyclone/auth/__init__.py @@ -0,0 +1 @@ +"""Auth module — users, sessions, permissions, routes, admin, rate_limit.""" \ No newline at end of file diff --git a/backend/src/cyclone/auth/users.py b/backend/src/cyclone/auth/users.py new file mode 100644 index 0000000..3107bcd --- /dev/null +++ b/backend/src/cyclone/auth/users.py @@ -0,0 +1,79 @@ +"""User CRUD + bcrypt password hashing.""" + +from __future__ import annotations + +from datetime import datetime, timezone +from typing import Any + +from passlib.hash import bcrypt +from sqlalchemy import select + +from cyclone.db import User + + +def hash_password(plaintext: str) -> str: + return bcrypt.hash(plaintext) + + +def verify_password(plaintext: str, hashed: str) -> bool: + try: + return bcrypt.verify(plaintext, hashed) + except (ValueError, TypeError): + return False + + +def create(db, *, username: str, password: str, role: str) -> User: + user = User( + username=username, + password_hash=hash_password(password), + role=role, + created_at=datetime.now(timezone.utc), + ) + db.add(user) + db.commit() + db.refresh(user) + return user + + +def get_by_username(db, username: str) -> User | None: + return db.execute( + select(User).where(User.username == username) + ).scalar_one_or_none() + + +def get(db, user_id: int) -> User | None: + return db.get(User, user_id) + + +def disable(db, user_id: int) -> None: + user = db.get(User, user_id) + if user is None: + return + user.disabled_at = datetime.now(timezone.utc) + db.commit() + + +def update_role(db, user_id: int, role: str) -> None: + user = db.get(User, user_id) + if user is None: + return + user.role = role + db.commit() + + +def update_password(db, user_id: int, new_password: str) -> None: + user = db.get(User, user_id) + if user is None: + return + user.password_hash = hash_password(new_password) + db.commit() + + +def to_public(user: User) -> dict[str, Any]: + return { + "id": user.id, + "username": user.username, + "role": user.role, + "createdAt": user.created_at.isoformat() if user.created_at else None, + "disabledAt": user.disabled_at.isoformat() if user.disabled_at else None, + } \ No newline at end of file diff --git a/backend/tests/test_auth_users.py b/backend/tests/test_auth_users.py new file mode 100644 index 0000000..b6ce096 --- /dev/null +++ b/backend/tests/test_auth_users.py @@ -0,0 +1,93 @@ +"""Unit tests for cyclone.auth.users — User CRUD + bcrypt hashing.""" + +from __future__ import annotations + +import pytest +from sqlalchemy import delete + +from cyclone.auth import users +from cyclone.db import SessionLocal, User + + +@pytest.fixture(autouse=True) +def _clear_users(): + with SessionLocal()() as db: + db.execute(delete(User)) + db.commit() + yield + with SessionLocal()() as db: + db.execute(delete(User)) + db.commit() + + +def test_hash_password_returns_bcrypt(): + h = users.hash_password("hunter2hunter2") + assert h.startswith("$2") + + +def test_hash_password_produces_unique_salts(): + a = users.hash_password("same-password") + b = users.hash_password("same-password") + assert a != b + + +def test_verify_password_correct(): + h = users.hash_password("hunter2hunter2") + assert users.verify_password("hunter2hunter2", h) is True + + +def test_verify_password_incorrect(): + h = users.hash_password("hunter2hunter2") + assert users.verify_password("WRONG", h) is False + + +def test_create_user_persists_with_hashed_password(): + with SessionLocal()() as db: + u = users.create(db, username="alice", password="hunter2hunter2", role="admin") + assert u.id is not None + assert u.username == "alice" + assert u.role == "admin" + assert u.disabled_at is None + assert u.password_hash != "hunter2hunter2" + assert u.password_hash.startswith("$2") + + +def test_create_user_rejects_duplicate_username(): + with SessionLocal()() as db: + users.create(db, username="bob", password="hunter2hunter2", role="user") + with SessionLocal()() as db: + with pytest.raises(Exception): + users.create(db, username="bob", password="anotherone", role="user") + + +def test_get_by_username_returns_user(): + with SessionLocal()() as db: + users.create(db, username="carol", password="hunter2hunter2", role="viewer") + with SessionLocal()() as db: + u = users.get_by_username(db, "carol") + assert u is not None + assert u.username == "carol" + + +def test_get_by_username_returns_none_for_missing(): + with SessionLocal()() as db: + assert users.get_by_username(db, "ghost") is None + + +def test_disable_user_sets_disabled_at(): + with SessionLocal()() as db: + u = users.create(db, username="dave", password="hunter2hunter2", role="user") + users.disable(db, u.id) + with SessionLocal()() as db: + refreshed = users.get_by_username(db, "dave") + assert refreshed.disabled_at is not None + + +def test_to_public_shape_omits_password_hash(): + with SessionLocal()() as db: + u = users.create(db, username="eve", password="hunter2hunter2", role="admin") + shape = users.to_public(u) + assert "password_hash" not in shape + assert shape["username"] == "eve" + assert shape["role"] == "admin" + assert "id" in shape and "createdAt" in shape \ No newline at end of file