Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 39 additions & 1 deletion backend/hr/test_apis.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
import json
import re
from datetime import date, timedelta
from unittest.mock import patch
from unittest.mock import patch, Mock
import pytest
from django.contrib.auth.models import User, Permission, AnonymousUser
from django.http import HttpResponse
Expand All @@ -23,6 +23,11 @@ def setUp(self):
"""HR Tests setUp"""
self.views = Views()
self.factory = RequestFactory()
self.user = User.objects.create_user(
username="apiuser",
email="api@example.com",
password="testpassword"
)
self.auth_user = User.objects.create_user(
username="clear-all-auth-user",
password="booking-pass-123"
Expand Down Expand Up @@ -700,6 +705,38 @@ def test_wrapper_views(self):
save_response = save_reservation_view(self.factory.get("/api/reservations"))
assert save_response.status_code == 405

def test_auth_status_success(self):
"""HR Test case test_auth_status_success"""
request = self.factory.get("/api/auth/status")
request.user = AnonymousUser()
response = Views.auth_status(request)
assert response.status_code == 200
data = json.loads(response.content.decode())
assert data["authenticated"] is False
assert data["username"] is None

def test_login_invalid_credentials(self):
"""HR Test case test_login_invalid_credentials"""
login_request = self.factory.post(
"/api/auth/login/",
data=json.dumps({"username": "apiuser", "password": "wrong-password"}),
content_type="application/json"
)
login_response = Views.login(login_request)
assert login_response.status_code == 401
login_data = json.loads(login_response.content.decode())
assert "Invalid credentials" in login_data["error"]

def test_logout_success(self):
"""HR Test case test_logout_success"""
logout_request = self.factory.post("/api/auth/logout/")
logout_request.session = Mock()
logout_request.user = self.user
logout_response = Views.logout(logout_request)
assert logout_response.status_code == 200
logout_data = json.loads(logout_response.content.decode())
assert logout_data["success"] is True

user_request = self.factory.get('/api/user/')
user_request.user = AnonymousUser()
user_response = current_user_view(user_request)
Expand Down Expand Up @@ -734,3 +771,4 @@ def test_openapi_uses_explicit_booking_models(self):
assert table_schema_ref == "#/components/schemas/BookingsResponse"
assert by_id_schema_ref == "#/components/schemas/BookingByIdResponse"
assert not_found_schema_ref == "#/components/schemas/NotFoundResponse"

6 changes: 6 additions & 0 deletions backend/hr/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
from hr.views import (
csrf_view,
auth_status_view,
login_view,
logout_view,
version_view,
current_user_view,
table_view,
Expand All @@ -18,6 +21,9 @@
path('docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),

path('csrf/', csrf_view),
path('auth/status', auth_status_view, name='auth_status'),
path('auth/login', login_view, name='login'),
path('auth/logout', logout_view, name='logout'),

path('version/', version_view, name='version'),

Expand Down
60 changes: 60 additions & 0 deletions backend/hr/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
from django.http import JsonResponse, HttpResponse, HttpResponseForbidden
from django.shortcuts import render, get_object_or_404, redirect
from django.core.handlers.wsgi import WSGIRequest
from django.contrib.auth import authenticate, login as auth_login, logout as auth_logout
from django.contrib.auth.views import redirect_to_login

from rest_framework.decorators import api_view
Expand Down Expand Up @@ -79,6 +80,46 @@ def version(cls, request:WSGIRequest):
logger.info('Application version (%s)', app_version)
return HttpResponse(str(app_version))

@classmethod
def auth_status(cls, request: WSGIRequest):
"""GET current authentication status"""
user = getattr(request, "user", None)
is_authenticated = bool(user and user.is_authenticated)
return JsonResponse({
"authenticated": is_authenticated,
"username": user.username if is_authenticated else None
}, status=200)

@classmethod
def login(cls, request: WSGIRequest):
"""POST login to create an authenticated session"""
if request.method != "POST":
return JsonResponse({"error": "Method not allowed."}, status=405)
try:
body = json.loads(request.body.decode("utf-8"))
except Exception:
return JsonResponse({"error": "Invalid JSON body"}, status=400)

username = body.get("username")
password = body.get("password")
if not username or not password:
return JsonResponse({"error": "Username and password are required."}, status=400)

user = authenticate(request, username=username, password=password)
if user is None:
return JsonResponse({"error": "Invalid credentials."}, status=401)

auth_login(request, user)
return JsonResponse({"success": True, "username": user.username}, status=200)

@classmethod
def logout(cls, request: WSGIRequest):
"""POST logout to clear authenticated session"""
if request.method != "POST":
return JsonResponse({"error": "Method not allowed."}, status=405)
auth_logout(request)
return JsonResponse({"success": True}, status=200)

@classmethod
def current_user(cls, request:WSGIRequest):
"""GET current logged-in user ID or 'unknown' if not authenticated"""
Expand Down Expand Up @@ -397,6 +438,25 @@ def _find_bookings_by_date(self, date):
def csrf_view(request):
return Views.csrf(request)

@extend_schema(
methods=["GET"],
description="GET current authentication status",
responses={200: OpenApiTypes.OBJECT}
)
@api_view(['GET'])
def auth_status_view(request):
return Views.auth_status(request)

@extend_schema(exclude=True)
@api_view(['POST'])
def login_view(request):
return Views.login(request)

@extend_schema(exclude=True)
@api_view(['POST'])
def logout_view(request):
return Views.logout(request)

@extend_schema(
methods=["GET"],
description="GET Application Version for current deployment",
Expand Down
18 changes: 16 additions & 2 deletions frontend/src/components/Navbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,22 @@ import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap/dist/js/bootstrap.bundle.min.js';
import '../css/style.css';
import { getAppVersion } from '../components/appVersionCache';
import { getAuthStatus } from '../components/auth';
import { getCurrentUser } from '../components/currentUserCache';

export const Navbar: React.FC = () => {
const [appVersion, setAppVersion] = useState<string>('…');
const [currentUser, setCurrentUser] = useState<string>('…');
const [appVersion, setAppVersion] = useState<string>('\u2026');
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
const [currentUser, setCurrentUser] = useState<string>('\u2026');

useEffect(() => {
let mounted = true;
getAppVersion()
.then(version => { if (mounted) setAppVersion(version); })
.catch(() => { if (mounted) setAppVersion('unknown'); });
getAuthStatus()
.then(status => { if (mounted) setIsAuthenticated(status.authenticated); })
.catch(() => { if (mounted) setIsAuthenticated(false); });
return () => { mounted = false; };
}, []);

Expand Down Expand Up @@ -70,6 +75,15 @@ export const Navbar: React.FC = () => {
<a className="nav-link text-white" target="_blank" rel="noopener noreferrer" href="/admin">
Django-Admin
</a>
{isAuthenticated ? (
<Link className="nav-link text-white" to="/logout">
Logout
</Link>
) : (
<Link className="nav-link text-white" to="/login">
Login
</Link>
)}
<a
href="https://github.com/conorheffron/booking-sys"
id="appVersion"
Expand Down
28 changes: 26 additions & 2 deletions frontend/src/components/__tests__/Navbar.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,13 +10,17 @@ jest.mock('../img/robot-logo.png', () => 'robot-logo.png');
jest.mock('../../components/appVersionCache', () => ({
getAppVersion: jest.fn(),
}));
jest.mock('../../components/auth', () => ({
getAuthStatus: jest.fn(),
}));

// Mock currentUserCache
jest.mock('../../components/currentUserCache', () => ({
getCurrentUser: jest.fn(),
}));

import { getAppVersion } from '../../components/appVersionCache';
import { getAuthStatus } from '../../components/auth';
import { getCurrentUser } from '../../components/currentUserCache';

// Helper to render with router context
Expand All @@ -32,6 +36,7 @@ describe('Navbar', () => {

it('renders logo, brand, and navigation links', () => {
(getAppVersion as jest.Mock).mockResolvedValue('1.2.3');
(getAuthStatus as jest.Mock).mockResolvedValue({ authenticated: false });
(getCurrentUser as jest.Mock).mockResolvedValue('test-user');
renderWithRouter(<Navbar />);
expect(screen.getByAltText('Logo')).toBeInTheDocument();
Expand All @@ -40,17 +45,20 @@ describe('Navbar', () => {
expect(screen.getByRole('link', { name: 'Bookings' })).toHaveAttribute('href', '/reservations');
expect(screen.getByRole('link', { name: 'Django-Admin' })).toHaveAttribute('href', '/admin');
expect(screen.getByRole('link', { name: 'Swagger' })).toHaveAttribute('href', '/api/docs/');
expect(screen.getByRole('link', { name: 'Login' })).toHaveAttribute('href', '/login');
});

it('renders initial version as ellipsis', () => {
(getAppVersion as jest.Mock).mockImplementation(() => new Promise(() => {}));
(getAuthStatus as jest.Mock).mockResolvedValue({ authenticated: false });
(getCurrentUser as jest.Mock).mockImplementation(() => new Promise(() => {}));
renderWithRouter(<Navbar />);
expect(screen.getByText(/Version: /)).toBeInTheDocument();
expect(screen.getByText(/Version: \u2026/)).toBeInTheDocument();
});

it('fetches and displays the app version on success', async () => {
(getAppVersion as jest.Mock).mockResolvedValue('1.2.3');
(getAuthStatus as jest.Mock).mockResolvedValue({ authenticated: false });
(getCurrentUser as jest.Mock).mockResolvedValue('test-user');
renderWithRouter(<Navbar />);
await waitFor(() => {
Expand All @@ -60,6 +68,7 @@ describe('Navbar', () => {

it('displays "unknown" if fetch fails', async () => {
(getAppVersion as jest.Mock).mockRejectedValue(new Error('Network error'));
(getAuthStatus as jest.Mock).mockResolvedValue({ authenticated: false });
(getCurrentUser as jest.Mock).mockResolvedValue('unknown');
renderWithRouter(<Navbar />);
await waitFor(() => {
Expand All @@ -69,6 +78,7 @@ describe('Navbar', () => {

it('has external link to the GitHub repo', () => {
(getAppVersion as jest.Mock).mockResolvedValue('1.2.3');
(getAuthStatus as jest.Mock).mockResolvedValue({ authenticated: false });
(getCurrentUser as jest.Mock).mockResolvedValue('test-user');
renderWithRouter(<Navbar />);
const link = screen.getByRole('link', { name: /Version:/ });
Expand All @@ -77,15 +87,27 @@ describe('Navbar', () => {
expect(link).toHaveAttribute('rel', expect.stringContaining('noopener'));
});

it('shows logout when user is authenticated', async () => {
(getAppVersion as jest.Mock).mockResolvedValue('1.2.3');
(getAuthStatus as jest.Mock).mockResolvedValue({ authenticated: true });
(getCurrentUser as jest.Mock).mockResolvedValue('admin');
renderWithRouter(<Navbar />);
await waitFor(() => {
expect(screen.getByRole('link', { name: 'Logout' })).toHaveAttribute('href', '/logout');
});
});

it('renders initial user state as ellipsis', () => {
(getAppVersion as jest.Mock).mockImplementation(() => new Promise(() => {}));
(getAuthStatus as jest.Mock).mockResolvedValue({ authenticated: false });
(getCurrentUser as jest.Mock).mockImplementation(() => new Promise(() => {}));
renderWithRouter(<Navbar />);
expect(screen.getByText(/User: /)).toBeInTheDocument();
expect(screen.getByText(/User: \u2026/)).toBeInTheDocument();
});

it('fetches and displays the current user on success', async () => {
(getAppVersion as jest.Mock).mockResolvedValue('1.2.3');
(getAuthStatus as jest.Mock).mockResolvedValue({ authenticated: true });
(getCurrentUser as jest.Mock).mockResolvedValue('admin');
renderWithRouter(<Navbar />);
await waitFor(() => {
Expand All @@ -95,6 +117,7 @@ describe('Navbar', () => {

it('displays "unknown" for user if fetch fails', async () => {
(getAppVersion as jest.Mock).mockResolvedValue('1.2.3');
(getAuthStatus as jest.Mock).mockResolvedValue({ authenticated: false });
(getCurrentUser as jest.Mock).mockRejectedValue(new Error('Network error'));
renderWithRouter(<Navbar />);
await waitFor(() => {
Expand All @@ -104,6 +127,7 @@ describe('Navbar', () => {

it('displays user ID in dropdown item', async () => {
(getAppVersion as jest.Mock).mockResolvedValue('1.2.3');
(getAuthStatus as jest.Mock).mockResolvedValue({ authenticated: true });
(getCurrentUser as jest.Mock).mockResolvedValue('johndoe');
renderWithRouter(<Navbar />);
await waitFor(() => {
Expand Down
46 changes: 46 additions & 0 deletions frontend/src/components/auth.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
export interface AuthStatus {
authenticated: boolean;
username?: string | null;
}

export async function getAuthStatus(): Promise<AuthStatus> {
const response = await fetch('/api/auth/status', {
credentials: 'include',
});
if (!response.ok) {
return { authenticated: false, username: null };
}
return response.json();
}

export async function loginUser(username: string, password: string, csrfToken: string) {
const response = await fetch('/api/auth/login', {
method: 'POST',
credentials: 'include',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken,
},
body: JSON.stringify({ username, password }),
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Login failed');
}
return data;
}

export async function logoutUser(csrfToken: string) {
const response = await fetch('/api/auth/logout', {
method: 'POST',
credentials: 'include',
headers: {
'X-CSRFToken': csrfToken,
},
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Logout failed');
}
return data;
}
Loading
Loading