diff --git a/backend/main.go b/backend/main.go index e76e0c1f..429c8378 100644 --- a/backend/main.go +++ b/backend/main.go @@ -82,13 +82,7 @@ func main() { store := sessions.NewCookieStore(sessionKey) // Configure secure cookie options - store.Options = &sessions.Options{ - Path: "/", - MaxAge: 86400 * 7, // 7 days - HttpOnly: true, // Prevent JavaScript access - Secure: os.Getenv("ENV") == "production", // HTTPS only in production - SameSite: http.SameSiteLaxMode, // CSRF protection (Lax allows OAuth redirects) - } + store.Options = sessionCookieOptions() gob.Register(map[string]interface{}{}) diff --git a/backend/session_options.go b/backend/session_options.go new file mode 100644 index 00000000..a7327983 --- /dev/null +++ b/backend/session_options.go @@ -0,0 +1,18 @@ +package main + +import ( + "net/http" + "os" + + "github.com/gorilla/sessions" +) + +func sessionCookieOptions() *sessions.Options { + return &sessions.Options{ + Path: "/", + MaxAge: 86400 * 7, // 7 days + HttpOnly: true, // Prevent JavaScript access + Secure: os.Getenv("ENV") == "production", // HTTPS only in production + SameSite: http.SameSiteLaxMode, // CSRF protection (Lax allows OAuth redirects) + } +} diff --git a/backend/session_options_test.go b/backend/session_options_test.go new file mode 100644 index 00000000..313137f5 --- /dev/null +++ b/backend/session_options_test.go @@ -0,0 +1,39 @@ +package main + +import ( + "net/http" + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSessionCookieOptions_Production(t *testing.T) { + t.Setenv("ENV", "production") + + opts := sessionCookieOptions() + + assert.Equal(t, "/", opts.Path) + assert.Equal(t, 86400*7, opts.MaxAge) + assert.True(t, opts.HttpOnly) + assert.True(t, opts.Secure) + assert.Equal(t, http.SameSiteLaxMode, opts.SameSite) +} + +func TestSessionCookieOptions_NonProduction(t *testing.T) { + t.Setenv("ENV", "development") + + opts := sessionCookieOptions() + + assert.Equal(t, 86400*7, opts.MaxAge) + assert.True(t, opts.HttpOnly) + assert.False(t, opts.Secure) +} + +func TestSessionCookieOptions_UnsetENV(t *testing.T) { + t.Setenv("ENV", "") + + opts := sessionCookieOptions() + + assert.Equal(t, 86400*7, opts.MaxAge) + assert.False(t, opts.Secure) +} diff --git a/frontend/src/components/LandingPage.tsx b/frontend/src/components/LandingPage.tsx index e176c8bc..68f83ceb 100644 --- a/frontend/src/components/LandingPage.tsx +++ b/frontend/src/components/LandingPage.tsx @@ -1,3 +1,6 @@ +import { useEffect } from 'react'; +import { useNavigate } from 'react-router'; +import { url } from '@/components/utils/URLs'; import { About } from './LandingComponents/About/About'; import { FAQ } from './LandingComponents/FAQ/FAQ'; import { Footer } from './LandingComponents/Footer/Footer'; @@ -9,6 +12,26 @@ import { Contact } from './LandingComponents/Contact/Contact'; import '../App.css'; export const LandingPage = () => { + const navigate = useNavigate(); + + useEffect(() => { + const redirectIfLoggedIn = async () => { + try { + const response = await fetch(url.backendURL + 'api/user', { + method: 'GET', + credentials: 'include', + }); + if (response.ok) { + navigate('/home'); + } + } catch (error) { + console.error('Error checking login status:', error); + } + }; + + redirectIfLoggedIn(); + }, [navigate]); + return (
diff --git a/frontend/src/components/__tests__/LandingPage.test.tsx b/frontend/src/components/__tests__/LandingPage.test.tsx index 79880182..564715da 100644 --- a/frontend/src/components/__tests__/LandingPage.test.tsx +++ b/frontend/src/components/__tests__/LandingPage.test.tsx @@ -1,6 +1,11 @@ -import { render, screen } from '@testing-library/react'; +import { render, screen, waitFor } from '@testing-library/react'; import { LandingPage } from '../LandingPage'; +const mockedNavigate = jest.fn(); +const consoleErrorSpy = jest + .spyOn(console, 'error') + .mockImplementation(() => {}); + // Mock dependencies jest.mock('../LandingComponents/Navbar/Navbar', () => ({ Navbar: () =>
Mocked Navbar
, @@ -27,7 +32,28 @@ jest.mock('../../components/utils/ScrollToTop', () => ({ ScrollToTop: () =>
Mocked ScrollToTop
, })); +jest.mock('react-router', () => ({ + useNavigate: () => mockedNavigate, +})); + +jest.mock('@/components/utils/URLs', () => ({ + url: { + backendURL: 'http://mocked-backend-url/', + }, +})); + +global.fetch = jest.fn(() => + Promise.resolve({ + ok: false, + }) +) as jest.Mock; + describe('LandingPage', () => { + afterEach(() => { + jest.clearAllMocks(); + consoleErrorSpy.mockClear(); + }); + it('renders all components correctly', () => { render(); @@ -40,6 +66,45 @@ describe('LandingPage', () => { expect(screen.getByText('Mocked Footer')).toBeInTheDocument(); expect(screen.getByText('Mocked ScrollToTop')).toBeInTheDocument(); }); + + it('redirects to /home when the user is already logged in', async () => { + (fetch as jest.Mock).mockResolvedValueOnce({ + ok: true, + }); + + render(); + + await waitFor(() => { + expect(mockedNavigate).toHaveBeenCalledWith('/home'); + }); + expect(fetch).toHaveBeenCalledWith('http://mocked-backend-url/api/user', { + method: 'GET', + credentials: 'include', + }); + }); + + it('stays on the landing page when the user is not logged in', async () => { + render(); + + await waitFor(() => { + expect(fetch).toHaveBeenCalledWith('http://mocked-backend-url/api/user', { + method: 'GET', + credentials: 'include', + }); + }); + expect(mockedNavigate).not.toHaveBeenCalled(); + }); + + it('stays on the landing page when the session check fails', async () => { + (fetch as jest.Mock).mockRejectedValueOnce(new Error('network error')); + + render(); + + await waitFor(() => { + expect(consoleErrorSpy).toHaveBeenCalled(); + }); + expect(mockedNavigate).not.toHaveBeenCalled(); + }); }); describe('LandingPage Component using Snapshot', () => { diff --git a/production/backend-deployment.yaml b/production/backend-deployment.yaml index 24b1ed38..4fe1a67f 100644 --- a/production/backend-deployment.yaml +++ b/production/backend-deployment.yaml @@ -40,6 +40,11 @@ spec: configMapKeyRef: key: CONTAINER_ORIGIN name: backend-env + - name: ENV + valueFrom: + configMapKeyRef: + key: ENV + name: backend-env - name: FRONTEND_ORIGIN_DEV valueFrom: configMapKeyRef: diff --git a/production/backend-env-configmap.yaml b/production/backend-env-configmap.yaml index 7bcf15ed..0e1fc8b0 100644 --- a/production/backend-env-configmap.yaml +++ b/production/backend-env-configmap.yaml @@ -3,6 +3,7 @@ data: CLIENT_ID: "YOUR_GOOGLE_CLOUD_AUTH_CLIENT_ID" # Replace this in order to access the frontend CLIENT_SEC: "YOUR_GOOGLE_CLOUD_AUTH_CLIENT_SECRET" # Replace this in order to access the frontend CONTAINER_ORIGIN: http://syncserver:8080/ + ENV: "production" # Required for secure HTTPS-only cookies FRONTEND_ORIGIN_DEV: http://localhost PORT: "8000" REDIRECT_URL_DEV: http://localhost:8000/auth/callback