mirror of
https://github.com/maziggy/bambuddy.git
synced 2026-09-30 03:01:21 +02:00
Post tasks for PR #117 (Authentication Feature)
Fixes: - Fixed is_auth_enabled() returning None instead of False when setting doesn't exist (in both auth.py and routes/auth.py) - Fixed get_current_user() crashing when credentials is None - Added missing async_session patch in conftest.py for auth tests Backup/Restore - Added Users Support - Added include_users parameter to backup export - Users are exported with username, role, and is_active (passwords excluded for security) - Restore creates users with temporary passwords that must be changed Backend Tests - 16 New Auth Tests - test_auth_api.py with tests for: - Auth status endpoint - Auth setup (enable/disable) - Login flow (success, invalid credentials, auth disabled) - /me endpoint with/without token - User management (list, create, update, delete) - Auth disable Frontend Tests - 6 New Login Tests - LoginPage.test.tsx with tests for: - Form rendering - Input validation - Login submission - Loading states Documentation Updates - CHANGELOG.md/README.md: Added authentication feature description - Website (features.html): Added new "Optional Authentication" section - Wiki: Created authentication.md with full documentation - Wiki index: Added authentication to features list
This commit is contained in:
@@ -71,6 +71,14 @@ All notable changes to Bambuddy will be documented in this file.
|
||||
- Three-dot menu button always visible on mobile (hover-only on desktop)
|
||||
- Selection checkbox always visible on mobile devices
|
||||
- Better PWA experience for file management
|
||||
- **Optional Authentication** - Secure your Bambuddy instance with user authentication:
|
||||
- Enable/disable authentication via Setup page or Settings → Users
|
||||
- Role-based access control: Admin and User roles
|
||||
- Admins have full access; Users can manage prints but not settings
|
||||
- JWT-based authentication with 7-day token expiration
|
||||
- User management page for creating, editing, and deleting users
|
||||
- Backward compatible: existing installations work without authentication
|
||||
- Settings page restricted to admin users when auth is enabled
|
||||
|
||||
### Changed
|
||||
- **Edit Queue Item modal** - Single printer selection only (reassigns item, doesn't duplicate)
|
||||
|
||||
@@ -134,6 +134,12 @@
|
||||
- Live application log viewer with filtering
|
||||
- Support bundle generator (privacy-filtered)
|
||||
|
||||
### 🔒 Optional Authentication
|
||||
- Enable/disable authentication any time
|
||||
- Role-based access (Admin/User)
|
||||
- JWT tokens with secure password hashing
|
||||
- User management (create, edit, delete)
|
||||
|
||||
</td>
|
||||
</tr>
|
||||
</table>
|
||||
|
||||
@@ -24,7 +24,9 @@ async def is_auth_enabled(db: AsyncSession) -> bool:
|
||||
"""Check if authentication is enabled."""
|
||||
result = await db.execute(select(Settings).where(Settings.key == "auth_enabled"))
|
||||
setting = result.scalar_one_or_none()
|
||||
return setting and setting.value.lower() == "true"
|
||||
if setting is None:
|
||||
return False
|
||||
return setting.value.lower() == "true"
|
||||
|
||||
|
||||
async def set_auth_enabled(db: AsyncSession, enabled: bool) -> None:
|
||||
|
||||
@@ -25,6 +25,7 @@ from backend.app.models.project import Project
|
||||
from backend.app.models.project_bom import ProjectBOMItem
|
||||
from backend.app.models.settings import Settings
|
||||
from backend.app.models.smart_plug import SmartPlug
|
||||
from backend.app.models.user import User
|
||||
from backend.app.schemas.settings import AppSettings, AppSettingsUpdate
|
||||
from backend.app.services.printer_manager import printer_manager
|
||||
from backend.app.services.spoolman import init_spoolman_client
|
||||
@@ -242,6 +243,9 @@ async def export_backup(
|
||||
include_pending_uploads: bool = Query(False, description="Include pending virtual printer uploads"),
|
||||
include_access_codes: bool = Query(False, description="Include printer access codes (security risk!)"),
|
||||
include_api_keys: bool = Query(False, description="Include API keys (keys will need to be regenerated on import)"),
|
||||
include_users: bool = Query(
|
||||
False, description="Include users (passwords not exported - users will need new passwords)"
|
||||
),
|
||||
):
|
||||
"""Export selected data as JSON backup."""
|
||||
backup: dict = {
|
||||
@@ -776,6 +780,22 @@ async def export_backup(
|
||||
)
|
||||
backup["included"].append("api_keys")
|
||||
|
||||
# Users (note: passwords not exported for security - users will need new passwords on import)
|
||||
if include_users:
|
||||
result = await db.execute(select(User))
|
||||
users = result.scalars().all()
|
||||
backup["users"] = []
|
||||
for user in users:
|
||||
backup["users"].append(
|
||||
{
|
||||
"username": user.username,
|
||||
"role": user.role,
|
||||
"is_active": user.is_active,
|
||||
# password_hash intentionally not exported for security
|
||||
}
|
||||
)
|
||||
backup["included"].append("users")
|
||||
|
||||
# If there are files to include (icons or archives), create ZIP file
|
||||
if backup_files:
|
||||
zip_buffer = io.BytesIO()
|
||||
@@ -866,6 +886,7 @@ async def import_backup(
|
||||
"maintenance_types": 0,
|
||||
"projects": 0,
|
||||
"pending_uploads": 0,
|
||||
"users": 0,
|
||||
}
|
||||
skipped = {
|
||||
"settings": 0,
|
||||
@@ -879,6 +900,7 @@ async def import_backup(
|
||||
"archives": 0,
|
||||
"projects": 0,
|
||||
"pending_uploads": 0,
|
||||
"users": 0,
|
||||
}
|
||||
skipped_details = {
|
||||
"notification_providers": [],
|
||||
@@ -890,6 +912,7 @@ async def import_backup(
|
||||
"archives": [],
|
||||
"projects": [],
|
||||
"pending_uploads": [],
|
||||
"users": [],
|
||||
}
|
||||
|
||||
# Restore settings (always overwrites)
|
||||
@@ -1772,6 +1795,40 @@ async def import_backup(
|
||||
}
|
||||
)
|
||||
|
||||
# Restore users (note: passwords not included in backup - users will need new passwords)
|
||||
# Users are skipped by default since they have no passwords; admin must recreate them
|
||||
new_users: list[str] = []
|
||||
if "users" in backup:
|
||||
from backend.app.core.auth import get_password_hash
|
||||
|
||||
for user_data in backup["users"]:
|
||||
result = await db.execute(select(User).where(User.username == user_data["username"]))
|
||||
existing = result.scalar_one_or_none()
|
||||
if existing:
|
||||
if overwrite:
|
||||
existing.role = user_data.get("role", "user")
|
||||
existing.is_active = user_data.get("is_active", True)
|
||||
# Don't change password - keep existing
|
||||
restored["users"] += 1
|
||||
else:
|
||||
skipped["users"] += 1
|
||||
skipped_details["users"].append(user_data["username"])
|
||||
else:
|
||||
# Create user with a temporary password that must be changed
|
||||
# Generate a random temporary password
|
||||
import secrets
|
||||
|
||||
temp_password = secrets.token_urlsafe(16)
|
||||
user = User(
|
||||
username=user_data["username"],
|
||||
password_hash=get_password_hash(temp_password),
|
||||
role=user_data.get("role", "user"),
|
||||
is_active=user_data.get("is_active", True),
|
||||
)
|
||||
db.add(user)
|
||||
restored["users"] += 1
|
||||
new_users.append(f"{user_data['username']} (temp password: {temp_password})")
|
||||
|
||||
await db.commit()
|
||||
|
||||
# If printers were in the backup (restored, updated, or skipped), reconnect all active printers
|
||||
@@ -1882,6 +1939,10 @@ async def import_backup(
|
||||
if new_api_keys:
|
||||
response["new_api_keys"] = new_api_keys
|
||||
|
||||
# Include newly created users with temp passwords (so admin can share them)
|
||||
if new_users:
|
||||
response["new_users"] = new_users
|
||||
|
||||
return response
|
||||
|
||||
|
||||
|
||||
@@ -81,7 +81,9 @@ async def is_auth_enabled(db: AsyncSession) -> bool:
|
||||
try:
|
||||
result = await db.execute(select(Settings).where(Settings.key == "auth_enabled"))
|
||||
setting = result.scalar_one_or_none()
|
||||
return setting and setting.value.lower() == "true"
|
||||
if setting is None:
|
||||
return False
|
||||
return setting.value.lower() == "true"
|
||||
except Exception:
|
||||
# If settings table doesn't exist or query fails, assume auth is disabled
|
||||
return False
|
||||
@@ -110,13 +112,17 @@ async def get_current_user_optional(
|
||||
return user
|
||||
|
||||
|
||||
async def get_current_user(credentials: Annotated[HTTPAuthorizationCredentials, Depends(security)]) -> User:
|
||||
async def get_current_user(
|
||||
credentials: Annotated[HTTPAuthorizationCredentials | None, Depends(security)] = None,
|
||||
) -> User:
|
||||
"""Get the current authenticated user from JWT token."""
|
||||
credentials_exception = HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Could not validate credentials",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
if credentials is None:
|
||||
raise credentials_exception
|
||||
try:
|
||||
token = credentials.credentials
|
||||
payload = jwt.decode(token, SECRET_KEY, algorithms=[ALGORITHM])
|
||||
|
||||
@@ -0,0 +1,362 @@
|
||||
"""Integration tests for Authentication API endpoints.
|
||||
|
||||
Tests the full request/response cycle for /api/v1/auth/ and /api/v1/users/ endpoints.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from httpx import AsyncClient
|
||||
|
||||
|
||||
class TestAuthStatusAPI:
|
||||
"""Integration tests for /api/v1/auth/status endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_get_auth_status_disabled(self, async_client: AsyncClient):
|
||||
"""Verify auth status returns disabled when not configured."""
|
||||
response = await async_client.get("/api/v1/auth/status")
|
||||
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert "auth_enabled" in result
|
||||
assert result["auth_enabled"] is False
|
||||
assert result["requires_setup"] is True
|
||||
|
||||
|
||||
class TestAuthSetupAPI:
|
||||
"""Integration tests for /api/v1/auth/setup endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_setup_auth_disabled(self, async_client: AsyncClient):
|
||||
"""Verify auth can be set up with auth disabled (no password required)."""
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/setup",
|
||||
json={"auth_enabled": False},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["auth_enabled"] is False
|
||||
assert result["admin_created"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_setup_auth_enabled_requires_credentials(self, async_client: AsyncClient):
|
||||
"""Verify enabling auth requires admin username and password."""
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/setup",
|
||||
json={"auth_enabled": True},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Admin username and password are required" in response.json()["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_setup_auth_enabled_with_credentials(self, async_client: AsyncClient):
|
||||
"""Verify auth can be enabled with admin credentials."""
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/setup",
|
||||
json={
|
||||
"auth_enabled": True,
|
||||
"admin_username": "testadmin",
|
||||
"admin_password": "testpassword123",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["auth_enabled"] is True
|
||||
assert result["admin_created"] is True
|
||||
|
||||
|
||||
class TestAuthLoginAPI:
|
||||
"""Integration tests for /api/v1/auth/login endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_login_auth_disabled(self, async_client: AsyncClient):
|
||||
"""Verify login fails when auth is not enabled."""
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "admin", "password": "password"},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Authentication is not enabled" in response.json()["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_login_success(self, async_client: AsyncClient):
|
||||
"""Verify login succeeds with valid credentials after setup."""
|
||||
# First enable auth
|
||||
await async_client.post(
|
||||
"/api/v1/auth/setup",
|
||||
json={
|
||||
"auth_enabled": True,
|
||||
"admin_username": "logintest",
|
||||
"admin_password": "loginpassword123",
|
||||
},
|
||||
)
|
||||
|
||||
# Now login
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "logintest", "password": "loginpassword123"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert "access_token" in result
|
||||
assert result["token_type"] == "bearer"
|
||||
assert result["user"]["username"] == "logintest"
|
||||
assert result["user"]["role"] == "admin"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_login_invalid_credentials(self, async_client: AsyncClient):
|
||||
"""Verify login fails with invalid credentials."""
|
||||
# First enable auth
|
||||
await async_client.post(
|
||||
"/api/v1/auth/setup",
|
||||
json={
|
||||
"auth_enabled": True,
|
||||
"admin_username": "invalidtest",
|
||||
"admin_password": "correctpassword",
|
||||
},
|
||||
)
|
||||
|
||||
# Try login with wrong password
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "invalidtest", "password": "wrongpassword"},
|
||||
)
|
||||
|
||||
assert response.status_code == 401
|
||||
assert "Incorrect username or password" in response.json()["detail"]
|
||||
|
||||
|
||||
class TestAuthMeAPI:
|
||||
"""Integration tests for /api/v1/auth/me endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_me_without_token(self, async_client: AsyncClient):
|
||||
"""Verify /me fails without authentication token."""
|
||||
response = await async_client.get("/api/v1/auth/me")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_me_with_valid_token(self, async_client: AsyncClient):
|
||||
"""Verify /me returns user info with valid token."""
|
||||
# Setup and login
|
||||
await async_client.post(
|
||||
"/api/v1/auth/setup",
|
||||
json={
|
||||
"auth_enabled": True,
|
||||
"admin_username": "metest",
|
||||
"admin_password": "mepassword123",
|
||||
},
|
||||
)
|
||||
|
||||
login_response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "metest", "password": "mepassword123"},
|
||||
)
|
||||
token = login_response.json()["access_token"]
|
||||
|
||||
# Get current user
|
||||
response = await async_client.get(
|
||||
"/api/v1/auth/me",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert result["username"] == "metest"
|
||||
assert result["role"] == "admin"
|
||||
assert result["is_active"] is True
|
||||
|
||||
|
||||
class TestUsersAPI:
|
||||
"""Integration tests for /api/v1/users/ endpoints."""
|
||||
|
||||
@pytest.fixture
|
||||
async def auth_token(self, async_client: AsyncClient):
|
||||
"""Setup auth and return admin token."""
|
||||
await async_client.post(
|
||||
"/api/v1/auth/setup",
|
||||
json={
|
||||
"auth_enabled": True,
|
||||
"admin_username": "usersadmin",
|
||||
"admin_password": "adminpassword123",
|
||||
},
|
||||
)
|
||||
|
||||
login_response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "usersadmin", "password": "adminpassword123"},
|
||||
)
|
||||
return login_response.json()["access_token"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_list_users_requires_auth(self, async_client: AsyncClient):
|
||||
"""Verify listing users requires authentication."""
|
||||
response = await async_client.get("/api/v1/users/")
|
||||
|
||||
assert response.status_code == 401
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_list_users_as_admin(self, async_client: AsyncClient, auth_token: str):
|
||||
"""Verify admin can list users."""
|
||||
response = await async_client.get(
|
||||
"/api/v1/users/",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
result = response.json()
|
||||
assert isinstance(result, list)
|
||||
assert len(result) >= 1 # At least the admin user
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_create_user(self, async_client: AsyncClient, auth_token: str):
|
||||
"""Verify admin can create a new user."""
|
||||
response = await async_client.post(
|
||||
"/api/v1/users/",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={
|
||||
"username": "newuser",
|
||||
"password": "newuserpassword",
|
||||
"role": "user",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 201
|
||||
result = response.json()
|
||||
assert result["username"] == "newuser"
|
||||
assert result["role"] == "user"
|
||||
assert result["is_active"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_create_user_duplicate_username(self, async_client: AsyncClient, auth_token: str):
|
||||
"""Verify creating user with duplicate username fails."""
|
||||
# Create first user
|
||||
await async_client.post(
|
||||
"/api/v1/users/",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={
|
||||
"username": "duplicateuser",
|
||||
"password": "password123",
|
||||
"role": "user",
|
||||
},
|
||||
)
|
||||
|
||||
# Try to create duplicate
|
||||
response = await async_client.post(
|
||||
"/api/v1/users/",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={
|
||||
"username": "duplicateuser",
|
||||
"password": "password456",
|
||||
"role": "user",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 400
|
||||
assert "Username already exists" in response.json()["detail"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_update_user(self, async_client: AsyncClient, auth_token: str):
|
||||
"""Verify admin can update a user."""
|
||||
# Create user
|
||||
create_response = await async_client.post(
|
||||
"/api/v1/users/",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={
|
||||
"username": "updateuser",
|
||||
"password": "password123",
|
||||
"role": "user",
|
||||
},
|
||||
)
|
||||
user_id = create_response.json()["id"]
|
||||
|
||||
# Update user
|
||||
response = await async_client.patch(
|
||||
f"/api/v1/users/{user_id}",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={"role": "admin"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["role"] == "admin"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_delete_user(self, async_client: AsyncClient, auth_token: str):
|
||||
"""Verify admin can delete a user."""
|
||||
# Create user
|
||||
create_response = await async_client.post(
|
||||
"/api/v1/users/",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
json={
|
||||
"username": "deleteuser",
|
||||
"password": "password123",
|
||||
"role": "user",
|
||||
},
|
||||
)
|
||||
user_id = create_response.json()["id"]
|
||||
|
||||
# Delete user
|
||||
response = await async_client.delete(
|
||||
f"/api/v1/users/{user_id}",
|
||||
headers={"Authorization": f"Bearer {auth_token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 204
|
||||
|
||||
|
||||
class TestAuthDisableAPI:
|
||||
"""Integration tests for /api/v1/auth/disable endpoint."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
@pytest.mark.integration
|
||||
async def test_disable_auth(self, async_client: AsyncClient):
|
||||
"""Verify admin can disable authentication."""
|
||||
# Setup auth
|
||||
await async_client.post(
|
||||
"/api/v1/auth/setup",
|
||||
json={
|
||||
"auth_enabled": True,
|
||||
"admin_username": "disableadmin",
|
||||
"admin_password": "adminpassword123",
|
||||
},
|
||||
)
|
||||
|
||||
# Login to get token
|
||||
login_response = await async_client.post(
|
||||
"/api/v1/auth/login",
|
||||
json={"username": "disableadmin", "password": "adminpassword123"},
|
||||
)
|
||||
token = login_response.json()["access_token"]
|
||||
|
||||
# Disable auth
|
||||
response = await async_client.post(
|
||||
"/api/v1/auth/disable",
|
||||
headers={"Authorization": f"Bearer {token}"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["auth_enabled"] is False
|
||||
|
||||
# Verify auth is now disabled
|
||||
status_response = await async_client.get("/api/v1/auth/status")
|
||||
assert status_response.json()["auth_enabled"] is False
|
||||
@@ -0,0 +1,157 @@
|
||||
/**
|
||||
* Tests for the LoginPage component.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import { screen, waitFor } from '@testing-library/react';
|
||||
import userEvent from '@testing-library/user-event';
|
||||
import { render } from '../utils';
|
||||
import { LoginPage } from '../../pages/LoginPage';
|
||||
import { http, HttpResponse } from 'msw';
|
||||
import { server } from '../mocks/server';
|
||||
|
||||
describe('LoginPage', () => {
|
||||
beforeEach(() => {
|
||||
server.use(
|
||||
http.get('/api/v1/auth/status', () => {
|
||||
return HttpResponse.json({ auth_enabled: true, requires_setup: false });
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
describe('rendering', () => {
|
||||
it('renders the login form', async () => {
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('heading', { name: /Bambuddy Login/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
|
||||
expect(screen.getByLabelText(/Password/i)).toBeInTheDocument();
|
||||
expect(screen.getByRole('button', { name: /Sign in/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders the sign in description', async () => {
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/Sign in to your account/i)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('form validation', () => {
|
||||
it('shows error when submitting empty form', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button', { name: /Sign in/i })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.click(screen.getByRole('button', { name: /Sign in/i }));
|
||||
|
||||
// The form has required fields, so HTML5 validation should prevent submission
|
||||
// or the component shows a toast
|
||||
});
|
||||
|
||||
it('allows entering username and password', async () => {
|
||||
const user = userEvent.setup();
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.type(screen.getByLabelText(/Username/i), 'testuser');
|
||||
await user.type(screen.getByLabelText(/Password/i), 'testpassword');
|
||||
|
||||
expect(screen.getByLabelText(/Username/i)).toHaveValue('testuser');
|
||||
expect(screen.getByLabelText(/Password/i)).toHaveValue('testpassword');
|
||||
});
|
||||
});
|
||||
|
||||
describe('login flow', () => {
|
||||
it('submits login request with credentials', async () => {
|
||||
const user = userEvent.setup();
|
||||
let loginCalled = false;
|
||||
|
||||
server.use(
|
||||
http.post('/api/v1/auth/login', async ({ request }) => {
|
||||
loginCalled = true;
|
||||
const body = await request.json() as { username: string; password: string };
|
||||
if (body.username === 'validuser' && body.password === 'validpass') {
|
||||
return HttpResponse.json({
|
||||
access_token: 'test-token',
|
||||
token_type: 'bearer',
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'validuser',
|
||||
role: 'admin',
|
||||
is_active: true,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
}
|
||||
return HttpResponse.json(
|
||||
{ detail: 'Incorrect username or password' },
|
||||
{ status: 401 }
|
||||
);
|
||||
})
|
||||
);
|
||||
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.type(screen.getByLabelText(/Username/i), 'validuser');
|
||||
await user.type(screen.getByLabelText(/Password/i), 'validpass');
|
||||
await user.click(screen.getByRole('button', { name: /Sign in/i }));
|
||||
|
||||
// Verify the login endpoint was called
|
||||
await waitFor(() => {
|
||||
expect(loginCalled).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
it('shows loading state during login', async () => {
|
||||
const user = userEvent.setup();
|
||||
|
||||
// Slow login endpoint
|
||||
server.use(
|
||||
http.post('/api/v1/auth/login', async () => {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
return HttpResponse.json({
|
||||
access_token: 'test-token',
|
||||
token_type: 'bearer',
|
||||
user: {
|
||||
id: 1,
|
||||
username: 'testuser',
|
||||
role: 'admin',
|
||||
is_active: true,
|
||||
created_at: new Date().toISOString(),
|
||||
},
|
||||
});
|
||||
})
|
||||
);
|
||||
|
||||
render(<LoginPage />);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText(/Username/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
await user.type(screen.getByLabelText(/Username/i), 'testuser');
|
||||
await user.type(screen.getByLabelText(/Password/i), 'testpass');
|
||||
await user.click(screen.getByRole('button', { name: /Sign in/i }));
|
||||
|
||||
// Check for loading state
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole('button')).toBeDisabled();
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user