Skip to content

Commit 1fe8f7f

Browse files
CopilotconorheffronCopilot
authored
Add session auth views and enforce permission-based update/delete booking operations (#408)
* Initial plan * feat: add login logout views and auth guard for booking updates/deletes Agent-Logs-Url: https://github.com/conorheffron/booking-sys/sessions/68e96142-58a7-4d10-b953-75190aeba1df Co-authored-by: conorheffron <8218626+conorheffron@users.noreply.github.com> * test: refine auth tests and accessibility states Agent-Logs-Url: https://github.com/conorheffron/booking-sys/sessions/68e96142-58a7-4d10-b953-75190aeba1df Co-authored-by: conorheffron <8218626+conorheffron@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * Fix handleDelete: add X-CSRFToken header and use functional state update * Fix invalid TypeScript fetch mock assignments in Reservationspage.test.tsx * Resolve merge conflicts with main branch * test: align ReservationsPage fetch mocks with cast-after-assignment pattern --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: conorheffron <8218626+conorheffron@users.noreply.github.com> Co-authored-by: Conor Heffron <conor.heffron@gmail.com> Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
1 parent fa9f127 commit 1fe8f7f

13 files changed

Lines changed: 557 additions & 31 deletions

File tree

backend/hr/test_apis.py

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import json
33
import re
44
from datetime import date, timedelta
5-
from unittest.mock import patch
5+
from unittest.mock import patch, Mock
66
import pytest
77
from django.contrib.auth.models import User, Permission, AnonymousUser
88
from django.http import HttpResponse
@@ -23,6 +23,11 @@ def setUp(self):
2323
"""HR Tests setUp"""
2424
self.views = Views()
2525
self.factory = RequestFactory()
26+
self.user = User.objects.create_user(
27+
username="apiuser",
28+
email="api@example.com",
29+
password="testpassword"
30+
)
2631
self.auth_user = User.objects.create_user(
2732
username="clear-all-auth-user",
2833
password="booking-pass-123"
@@ -700,6 +705,38 @@ def test_wrapper_views(self):
700705
save_response = save_reservation_view(self.factory.get("/api/reservations"))
701706
assert save_response.status_code == 405
702707

708+
def test_auth_status_success(self):
709+
"""HR Test case test_auth_status_success"""
710+
request = self.factory.get("/api/auth/status")
711+
request.user = AnonymousUser()
712+
response = Views.auth_status(request)
713+
assert response.status_code == 200
714+
data = json.loads(response.content.decode())
715+
assert data["authenticated"] is False
716+
assert data["username"] is None
717+
718+
def test_login_invalid_credentials(self):
719+
"""HR Test case test_login_invalid_credentials"""
720+
login_request = self.factory.post(
721+
"/api/auth/login/",
722+
data=json.dumps({"username": "apiuser", "password": "wrong-password"}),
723+
content_type="application/json"
724+
)
725+
login_response = Views.login(login_request)
726+
assert login_response.status_code == 401
727+
login_data = json.loads(login_response.content.decode())
728+
assert "Invalid credentials" in login_data["error"]
729+
730+
def test_logout_success(self):
731+
"""HR Test case test_logout_success"""
732+
logout_request = self.factory.post("/api/auth/logout/")
733+
logout_request.session = Mock()
734+
logout_request.user = self.user
735+
logout_response = Views.logout(logout_request)
736+
assert logout_response.status_code == 200
737+
logout_data = json.loads(logout_response.content.decode())
738+
assert logout_data["success"] is True
739+
703740
user_request = self.factory.get('/api/user/')
704741
user_request.user = AnonymousUser()
705742
user_response = current_user_view(user_request)
@@ -734,3 +771,4 @@ def test_openapi_uses_explicit_booking_models(self):
734771
assert table_schema_ref == "#/components/schemas/BookingsResponse"
735772
assert by_id_schema_ref == "#/components/schemas/BookingByIdResponse"
736773
assert not_found_schema_ref == "#/components/schemas/NotFoundResponse"
774+

backend/hr/urls.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,9 @@
55
from drf_spectacular.views import SpectacularAPIView, SpectacularSwaggerView
66
from hr.views import (
77
csrf_view,
8+
auth_status_view,
9+
login_view,
10+
logout_view,
811
version_view,
912
current_user_view,
1013
table_view,
@@ -18,6 +21,9 @@
1821
path('docs/', SpectacularSwaggerView.as_view(url_name='schema'), name='swagger-ui'),
1922

2023
path('csrf/', csrf_view),
24+
path('auth/status', auth_status_view, name='auth_status'),
25+
path('auth/login', login_view, name='login'),
26+
path('auth/logout', logout_view, name='logout'),
2127

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

backend/hr/views.py

Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
from django.http import JsonResponse, HttpResponse, HttpResponseForbidden
77
from django.shortcuts import render, get_object_or_404, redirect
88
from django.core.handlers.wsgi import WSGIRequest
9+
from django.contrib.auth import authenticate, login as auth_login, logout as auth_logout
910
from django.contrib.auth.views import redirect_to_login
1011

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

83+
@classmethod
84+
def auth_status(cls, request: WSGIRequest):
85+
"""GET current authentication status"""
86+
user = getattr(request, "user", None)
87+
is_authenticated = bool(user and user.is_authenticated)
88+
return JsonResponse({
89+
"authenticated": is_authenticated,
90+
"username": user.username if is_authenticated else None
91+
}, status=200)
92+
93+
@classmethod
94+
def login(cls, request: WSGIRequest):
95+
"""POST login to create an authenticated session"""
96+
if request.method != "POST":
97+
return JsonResponse({"error": "Method not allowed."}, status=405)
98+
try:
99+
body = json.loads(request.body.decode("utf-8"))
100+
except Exception:
101+
return JsonResponse({"error": "Invalid JSON body"}, status=400)
102+
103+
username = body.get("username")
104+
password = body.get("password")
105+
if not username or not password:
106+
return JsonResponse({"error": "Username and password are required."}, status=400)
107+
108+
user = authenticate(request, username=username, password=password)
109+
if user is None:
110+
return JsonResponse({"error": "Invalid credentials."}, status=401)
111+
112+
auth_login(request, user)
113+
return JsonResponse({"success": True, "username": user.username}, status=200)
114+
115+
@classmethod
116+
def logout(cls, request: WSGIRequest):
117+
"""POST logout to clear authenticated session"""
118+
if request.method != "POST":
119+
return JsonResponse({"error": "Method not allowed."}, status=405)
120+
auth_logout(request)
121+
return JsonResponse({"success": True}, status=200)
122+
82123
@classmethod
83124
def current_user(cls, request:WSGIRequest):
84125
"""GET current logged-in user ID or 'unknown' if not authenticated"""
@@ -397,6 +438,25 @@ def _find_bookings_by_date(self, date):
397438
def csrf_view(request):
398439
return Views.csrf(request)
399440

441+
@extend_schema(
442+
methods=["GET"],
443+
description="GET current authentication status",
444+
responses={200: OpenApiTypes.OBJECT}
445+
)
446+
@api_view(['GET'])
447+
def auth_status_view(request):
448+
return Views.auth_status(request)
449+
450+
@extend_schema(exclude=True)
451+
@api_view(['POST'])
452+
def login_view(request):
453+
return Views.login(request)
454+
455+
@extend_schema(exclude=True)
456+
@api_view(['POST'])
457+
def logout_view(request):
458+
return Views.logout(request)
459+
400460
@extend_schema(
401461
methods=["GET"],
402462
description="GET Application Version for current deployment",

frontend/src/components/Navbar.tsx

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,17 +5,22 @@ import 'bootstrap/dist/css/bootstrap.min.css';
55
import 'bootstrap/dist/js/bootstrap.bundle.min.js';
66
import '../css/style.css';
77
import { getAppVersion } from '../components/appVersionCache';
8+
import { getAuthStatus } from '../components/auth';
89
import { getCurrentUser } from '../components/currentUserCache';
910

1011
export const Navbar: React.FC = () => {
11-
const [appVersion, setAppVersion] = useState<string>('…');
12-
const [currentUser, setCurrentUser] = useState<string>('…');
12+
const [appVersion, setAppVersion] = useState<string>('\u2026');
13+
const [isAuthenticated, setIsAuthenticated] = useState<boolean>(false);
14+
const [currentUser, setCurrentUser] = useState<string>('\u2026');
1315

1416
useEffect(() => {
1517
let mounted = true;
1618
getAppVersion()
1719
.then(version => { if (mounted) setAppVersion(version); })
1820
.catch(() => { if (mounted) setAppVersion('unknown'); });
21+
getAuthStatus()
22+
.then(status => { if (mounted) setIsAuthenticated(status.authenticated); })
23+
.catch(() => { if (mounted) setIsAuthenticated(false); });
1924
return () => { mounted = false; };
2025
}, []);
2126

@@ -70,6 +75,15 @@ export const Navbar: React.FC = () => {
7075
<a className="nav-link text-white" target="_blank" rel="noopener noreferrer" href="/admin">
7176
Django-Admin
7277
</a>
78+
{isAuthenticated ? (
79+
<Link className="nav-link text-white" to="/logout">
80+
Logout
81+
</Link>
82+
) : (
83+
<Link className="nav-link text-white" to="/login">
84+
Login
85+
</Link>
86+
)}
7387
<a
7488
href="https://github.com/conorheffron/booking-sys"
7589
id="appVersion"

frontend/src/components/__tests__/Navbar.test.tsx

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -10,13 +10,17 @@ jest.mock('../img/robot-logo.png', () => 'robot-logo.png');
1010
jest.mock('../../components/appVersionCache', () => ({
1111
getAppVersion: jest.fn(),
1212
}));
13+
jest.mock('../../components/auth', () => ({
14+
getAuthStatus: jest.fn(),
15+
}));
1316

1417
// Mock currentUserCache
1518
jest.mock('../../components/currentUserCache', () => ({
1619
getCurrentUser: jest.fn(),
1720
}));
1821

1922
import { getAppVersion } from '../../components/appVersionCache';
23+
import { getAuthStatus } from '../../components/auth';
2024
import { getCurrentUser } from '../../components/currentUserCache';
2125

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

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

4551
it('renders initial version as ellipsis', () => {
4652
(getAppVersion as jest.Mock).mockImplementation(() => new Promise(() => {}));
53+
(getAuthStatus as jest.Mock).mockResolvedValue({ authenticated: false });
4754
(getCurrentUser as jest.Mock).mockImplementation(() => new Promise(() => {}));
4855
renderWithRouter(<Navbar />);
49-
expect(screen.getByText(/Version: /)).toBeInTheDocument();
56+
expect(screen.getByText(/Version: \u2026/)).toBeInTheDocument();
5057
});
5158

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

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

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

90+
it('shows logout when user is authenticated', async () => {
91+
(getAppVersion as jest.Mock).mockResolvedValue('1.2.3');
92+
(getAuthStatus as jest.Mock).mockResolvedValue({ authenticated: true });
93+
(getCurrentUser as jest.Mock).mockResolvedValue('admin');
94+
renderWithRouter(<Navbar />);
95+
await waitFor(() => {
96+
expect(screen.getByRole('link', { name: 'Logout' })).toHaveAttribute('href', '/logout');
97+
});
98+
});
99+
80100
it('renders initial user state as ellipsis', () => {
81101
(getAppVersion as jest.Mock).mockImplementation(() => new Promise(() => {}));
102+
(getAuthStatus as jest.Mock).mockResolvedValue({ authenticated: false });
82103
(getCurrentUser as jest.Mock).mockImplementation(() => new Promise(() => {}));
83104
renderWithRouter(<Navbar />);
84-
expect(screen.getByText(/User: /)).toBeInTheDocument();
105+
expect(screen.getByText(/User: \u2026/)).toBeInTheDocument();
85106
});
86107

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

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

105128
it('displays user ID in dropdown item', async () => {
106129
(getAppVersion as jest.Mock).mockResolvedValue('1.2.3');
130+
(getAuthStatus as jest.Mock).mockResolvedValue({ authenticated: true });
107131
(getCurrentUser as jest.Mock).mockResolvedValue('johndoe');
108132
renderWithRouter(<Navbar />);
109133
await waitFor(() => {

frontend/src/components/auth.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
export interface AuthStatus {
2+
authenticated: boolean;
3+
username?: string | null;
4+
}
5+
6+
export async function getAuthStatus(): Promise<AuthStatus> {
7+
const response = await fetch('/api/auth/status', {
8+
credentials: 'include',
9+
});
10+
if (!response.ok) {
11+
return { authenticated: false, username: null };
12+
}
13+
return response.json();
14+
}
15+
16+
export async function loginUser(username: string, password: string, csrfToken: string) {
17+
const response = await fetch('/api/auth/login', {
18+
method: 'POST',
19+
credentials: 'include',
20+
headers: {
21+
'Content-Type': 'application/json',
22+
'X-CSRFToken': csrfToken,
23+
},
24+
body: JSON.stringify({ username, password }),
25+
});
26+
const data = await response.json();
27+
if (!response.ok) {
28+
throw new Error(data.error || 'Login failed');
29+
}
30+
return data;
31+
}
32+
33+
export async function logoutUser(csrfToken: string) {
34+
const response = await fetch('/api/auth/logout', {
35+
method: 'POST',
36+
credentials: 'include',
37+
headers: {
38+
'X-CSRFToken': csrfToken,
39+
},
40+
});
41+
const data = await response.json();
42+
if (!response.ok) {
43+
throw new Error(data.error || 'Logout failed');
44+
}
45+
return data;
46+
}

0 commit comments

Comments
 (0)