From b4fcaeb89316516c3eb140068a40ba0d97be5dae Mon Sep 17 00:00:00 2001 From: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> Date: Wed, 19 Aug 2026 21:42:52 +0530 Subject: [PATCH 01/20] add seed users and test redirection for missing session_id Signed-off-by: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> --- .github/workflows/run-e2e-tests.yaml | 10 + .../apps/nest/api/internal/views/__init__.py | 0 .../apps/nest/api/internal/views/e2e_login.py | 37 ++++ .../management/commands/e2e_seed_users.py | 57 ++++++ backend/src/settings/urls.py | 5 + .../nest/api/internal/views/e2e_login_test.py | 87 +++++++++ .../commands/e2e_seed_users_test.py | 58 ++++++ docker-compose/e2e/compose.yaml | 1 + docker/backend/Dockerfile | 4 +- docker/backend/Dockerfile.local | 2 +- e2e/PROTECTED_TESTS_EXPLAINED.md | 179 ++++++++++++++++++ e2e/helpers/loginAs.ts | 11 ++ e2e/make/test.mk | 22 ++- e2e/package.json | 1 + e2e/pages/protected/MyMentorship.spec.ts | 37 ++++ frontend/next.config.ts | 1 + 16 files changed, 507 insertions(+), 5 deletions(-) create mode 100644 backend/src/apps/nest/api/internal/views/__init__.py create mode 100644 backend/src/apps/nest/api/internal/views/e2e_login.py create mode 100644 backend/src/apps/nest/management/commands/e2e_seed_users.py create mode 100644 backend/tests/unit/apps/nest/api/internal/views/e2e_login_test.py create mode 100644 backend/tests/unit/apps/nest/management/commands/e2e_seed_users_test.py create mode 100644 e2e/PROTECTED_TESTS_EXPLAINED.md create mode 100644 e2e/helpers/loginAs.ts create mode 100644 e2e/pages/protected/MyMentorship.spec.ts diff --git a/.github/workflows/run-e2e-tests.yaml b/.github/workflows/run-e2e-tests.yaml index 8ecdaf5a16..7d02c0cf2e 100644 --- a/.github/workflows/run-e2e-tests.yaml +++ b/.github/workflows/run-e2e-tests.yaml @@ -68,6 +68,16 @@ jobs: db_username: nest_user_e2e env_file: .env.e2e-tests + - name: Seed e2e users + run: | + set -euo pipefail && set -a && source .env.e2e-tests && set +a + export DJANGO_DB_HOST=localhost + export DJANGO_REDIS_AUTH_ENABLED=False + export DJANGO_REDIS_HOST=localhost + export PYTHONPATH="${PWD}/src" + poetry run python manage.py e2e_seed_users + working-directory: backend + - name: Install frontend dependencies uses: ./.github/actions/install-frontend-dependencies diff --git a/backend/src/apps/nest/api/internal/views/__init__.py b/backend/src/apps/nest/api/internal/views/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/backend/src/apps/nest/api/internal/views/e2e_login.py b/backend/src/apps/nest/api/internal/views/e2e_login.py new file mode 100644 index 0000000000..88110015e6 --- /dev/null +++ b/backend/src/apps/nest/api/internal/views/e2e_login.py @@ -0,0 +1,37 @@ +"""E2E-only login view.""" + +import json + +from django.conf import settings +from django.contrib.auth import login +from django.http import Http404, HttpRequest, JsonResponse +from django.views.decorators.csrf import csrf_exempt +from django.views.decorators.http import require_POST + +from apps.nest.models import User + + +@csrf_exempt # NOSEMGREP: python.django.security.audit.csrf-exempt.no-csrf-exempt # NOSONAR +@require_POST +def e2e_login(request: HttpRequest) -> JsonResponse: + """Log in a seeded e2e user and set the Django session cookie.""" + if not settings.IS_E2E_ENVIRONMENT: + raise Http404 + + try: + payload = json.loads(request.body or b"{}") + except json.JSONDecodeError: + return JsonResponse({"message": "Invalid JSON.", "ok": False}, status=400) + + username = (payload.get("username") or "").strip() + if not username: + return JsonResponse({"message": "username is required.", "ok": False}, status=400) + + try: + user = User.objects.get(username=username) + except User.DoesNotExist as exc: + raise Http404 from exc + + login(request, user) + + return JsonResponse({"ok": True, "username": username}) diff --git a/backend/src/apps/nest/management/commands/e2e_seed_users.py b/backend/src/apps/nest/management/commands/e2e_seed_users.py new file mode 100644 index 0000000000..6345e76473 --- /dev/null +++ b/backend/src/apps/nest/management/commands/e2e_seed_users.py @@ -0,0 +1,57 @@ +"""Seed deterministic users for end-to-end tests.""" + +from django.conf import settings +from django.core.management.base import BaseCommand, CommandError +from django.utils import timezone + +from apps.core.utils import index +from apps.github.models.user import User as GithubUser +from apps.mentorship.models import Mentee, Mentor +from apps.nest.models import User as NestUser + +E2E_USERS = ( + ("e2e-mentee", "mentee"), + ("e2e-mentor", "mentor"), + ("e2e-user", ""), +) + + +class Command(BaseCommand): + help = "Seed e2e test users." + + def handle(self, *_args, **_options) -> None: + """Create GitHub, Nest, and mentorship users for e2e tests.""" + if not settings.IS_E2E_ENVIRONMENT: + error_message = "This command can only run in the e2e environment." + raise CommandError(error_message) + + now = timezone.now() + with index.disable_indexing(): + for login, role in E2E_USERS: + github_user, _ = GithubUser.objects.get_or_create( + login=login, + defaults={ + "created_at": now, + "email": f"{login}@example.com", + "name": login, + "node_id": f"e2e_node_{login}", + "updated_at": now, + }, + ) + nest_user, _ = NestUser.objects.get_or_create( + username=login, + defaults={ + "email": f"{login}@example.com", + "github_user": github_user, + }, + ) + if role == "mentor": + Mentor.objects.get_or_create( + github_user=github_user, + defaults={"nest_user": nest_user}, + ) + elif role == "mentee": + Mentee.objects.get_or_create( + github_user=github_user, + defaults={"nest_user": nest_user}, + ) diff --git a/backend/src/settings/urls.py b/backend/src/settings/urls.py index 67cfae2297..581d84586b 100644 --- a/backend/src/settings/urls.py +++ b/backend/src/settings/urls.py @@ -48,5 +48,10 @@ path("integrations/slack/interactivity/", slack_request_handler), ] +if settings.IS_E2E_ENVIRONMENT: + from apps.nest.api.internal.views.e2e_login import e2e_login + + urlpatterns += [path("e2e/login/", e2e_login)] + if settings.DEBUG: urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/backend/tests/unit/apps/nest/api/internal/views/e2e_login_test.py b/backend/tests/unit/apps/nest/api/internal/views/e2e_login_test.py new file mode 100644 index 0000000000..283dc1d880 --- /dev/null +++ b/backend/tests/unit/apps/nest/api/internal/views/e2e_login_test.py @@ -0,0 +1,87 @@ +import json +from http import HTTPStatus +from unittest.mock import MagicMock, patch + +import pytest +from django.http import Http404 +from django.test import RequestFactory + +from apps.nest.api.internal.views.e2e_login import e2e_login +from apps.nest.models import User + + +def _post(body: dict | str): + data = body if isinstance(body, str) else json.dumps(body) + return RequestFactory().post( + "/e2e/login/", + data=data, + content_type="application/json", + ) + + +class TestE2ELoginView: + def test_returns_404_outside_e2e(self): + with ( + patch( + "apps.nest.api.internal.views.e2e_login.settings.IS_E2E_ENVIRONMENT", + False, + ), + pytest.raises(Http404), + ): + e2e_login(_post({"username": "e2e-mentor"})) + + def test_returns_400_for_invalid_json(self): + with patch( + "apps.nest.api.internal.views.e2e_login.settings.IS_E2E_ENVIRONMENT", + True, + ): + response = e2e_login(_post("{")) + + assert response.status_code == HTTPStatus.BAD_REQUEST + assert json.loads(response.content)["ok"] is False + + def test_returns_400_without_username(self): + with patch( + "apps.nest.api.internal.views.e2e_login.settings.IS_E2E_ENVIRONMENT", + True, + ): + response = e2e_login(_post({})) + + assert response.status_code == HTTPStatus.BAD_REQUEST + assert json.loads(response.content)["ok"] is False + + def test_returns_404_for_unknown_user(self): + with ( + patch( + "apps.nest.api.internal.views.e2e_login.settings.IS_E2E_ENVIRONMENT", + True, + ), + patch( + "apps.nest.api.internal.views.e2e_login.User.objects.get", + side_effect=User.DoesNotExist, + ), + pytest.raises(Http404), + ): + e2e_login(_post({"username": "missing"})) + + def test_logs_in_user(self): + user = MagicMock() + request = _post({"username": "e2e-mentor"}) + + with ( + patch( + "apps.nest.api.internal.views.e2e_login.settings.IS_E2E_ENVIRONMENT", + True, + ), + patch( + "apps.nest.api.internal.views.e2e_login.User.objects.get", + return_value=user, + ) as mock_get, + patch("apps.nest.api.internal.views.e2e_login.login") as mock_login, + ): + response = e2e_login(request) + + mock_get.assert_called_once_with(username="e2e-mentor") + mock_login.assert_called_once_with(request, user) + assert response.status_code == HTTPStatus.OK + assert json.loads(response.content) == {"ok": True, "username": "e2e-mentor"} diff --git a/backend/tests/unit/apps/nest/management/commands/e2e_seed_users_test.py b/backend/tests/unit/apps/nest/management/commands/e2e_seed_users_test.py new file mode 100644 index 0000000000..8914bc5017 --- /dev/null +++ b/backend/tests/unit/apps/nest/management/commands/e2e_seed_users_test.py @@ -0,0 +1,58 @@ +from unittest.mock import MagicMock, patch + +import pytest +from django.core.management import call_command +from django.core.management.base import CommandError + +from apps.nest.management.commands.e2e_seed_users import Command, E2E_USERS + + +class TestE2ESeedUsersCommand: + def test_metadata(self): + assert Command.help == "Seed e2e test users." + assert E2E_USERS == ( + ("e2e-mentee", "mentee"), + ("e2e-mentor", "mentor"), + ("e2e-user", ""), + ) + + def test_requires_e2e_environment(self): + with ( + patch( + "apps.nest.management.commands.e2e_seed_users.settings.IS_E2E_ENVIRONMENT", + False, + ), + pytest.raises(CommandError, match="e2e environment"), + ): + call_command("e2e_seed_users") + + @patch("apps.nest.management.commands.e2e_seed_users.index.disable_indexing") + @patch("apps.nest.management.commands.e2e_seed_users.Mentee") + @patch("apps.nest.management.commands.e2e_seed_users.Mentor") + @patch("apps.nest.management.commands.e2e_seed_users.NestUser") + @patch("apps.nest.management.commands.e2e_seed_users.GithubUser") + def test_creates_users( + self, mock_github_user, mock_nest_user, mock_mentor, mock_mentee, mock_disable_indexing + ): + github_user = MagicMock() + nest_user = MagicMock() + mock_github_user.objects.get_or_create.return_value = (github_user, True) + mock_nest_user.objects.get_or_create.return_value = (nest_user, True) + + with patch( + "apps.nest.management.commands.e2e_seed_users.settings.IS_E2E_ENVIRONMENT", + True, + ): + call_command("e2e_seed_users") + + mock_disable_indexing.assert_called_once() + assert mock_github_user.objects.get_or_create.call_count == 3 + assert mock_nest_user.objects.get_or_create.call_count == 3 + mock_mentor.objects.get_or_create.assert_called_once_with( + github_user=github_user, + defaults={"nest_user": nest_user}, + ) + mock_mentee.objects.get_or_create.assert_called_once_with( + github_user=github_user, + defaults={"nest_user": nest_user}, + ) diff --git a/docker-compose/e2e/compose.yaml b/docker-compose/e2e/compose.yaml index e8b00ed9f9..84d07ab858 100644 --- a/docker-compose/e2e/compose.yaml +++ b/docker-compose/e2e/compose.yaml @@ -7,6 +7,7 @@ services: command: > sh -c ' python manage.py migrate && + python manage.py e2e_seed_users && gunicorn wsgi:application --bind 0.0.0.0:9000 ' container_name: e2e-nest-backend diff --git a/docker/backend/Dockerfile b/docker/backend/Dockerfile index fa4acc3015..ecb84f488b 100644 --- a/docker/backend/Dockerfile +++ b/docker/backend/Dockerfile @@ -67,7 +67,7 @@ ARG OWASP_UID RUN apk --no-cache update && \ apk --no-cache upgrade && \ - apk --no-cache add make postgresql16-client=16.14-r0 && \ + apk --no-cache add make postgresql16-client=16.15-r0 && \ rm -rf /var/cache/apk/* /var/tmp/* /tmp/* && \ addgroup -S -g ${OWASP_GID} owasp && \ adduser -S -h /home/owasp -u ${OWASP_UID} -G owasp owasp @@ -118,7 +118,7 @@ ARG OWASP_UID RUN apk --no-cache update && \ apk --no-cache upgrade && \ - apk --no-cache add ffmpeg make postgresql16-client=16.14-r0 \ + apk --no-cache add ffmpeg make postgresql16-client=16.15-r0 \ # WeasyPrint dependencies: https://doc.courtbouillon.org/weasyprint/stable/first_steps.html so:libfontconfig.so.1 so:libgobject-2.0.so.0 so:libharfbuzz-subset.so.0 \ so:libharfbuzz.so.0 so:libpango-1.0.so.0 so:libpangoft2-1.0.so.0 ttf-freefont && \ diff --git a/docker/backend/Dockerfile.local b/docker/backend/Dockerfile.local index a81e0b61f3..9fa1e8823b 100644 --- a/docker/backend/Dockerfile.local +++ b/docker/backend/Dockerfile.local @@ -57,7 +57,7 @@ RUN mkdir -p ${APK_CACHE_DIR} && \ RUN --mount=type=cache,target=${APK_CACHE_DIR} \ apk --no-cache update && \ - apk --no-cache add postgresql16-client=16.14-r0 redis && \ + apk --no-cache add postgresql16-client=16.15-r0 redis && \ addgroup -S -g ${OWASP_GID} owasp && \ adduser -S -h /home/owasp -u ${OWASP_UID} -G owasp owasp diff --git a/e2e/PROTECTED_TESTS_EXPLAINED.md b/e2e/PROTECTED_TESTS_EXPLAINED.md new file mode 100644 index 0000000000..c28bcdbcc5 --- /dev/null +++ b/e2e/PROTECTED_TESTS_EXPLAINED.md @@ -0,0 +1,179 @@ +# Protected Routes & E2E Testing Architecture + +This document explains in detail how end-to-end (E2E) testing works on authenticated/protected routes in OWASP Nest, how test users are seeded, how authentication is simulated ("bypassed") without GitHub OAuth, and a thorough security analysis of whether this represents a vulnerability. + +--- + +## 1. System Architecture & Layers Involved + +OWASP Nest is a decoupled full-stack application consisting of multiple distinct layers: + +```mermaid +flowchart TD + subgraph TestRunner ["1. Test Orchestration Layer"] + PW["Playwright (e2e-tests container)"] + end + + subgraph FrontendLayer ["2. Frontend Layer (Next.js - Port 3000)"] + NextProxy["Next.js Route Middleware (src/proxy.ts)"] + NextConfig["Next.js Rewrites (next.config.ts)"] + NextAuth["NextAuth JWT Session Handler"] + end + + subgraph BackendLayer ["3. Backend Layer (Django - Port 9000)"] + Urls["URL Router (settings/urls.py)"] + E2ELoginView["E2E Login View (apps/nest/api/internal/views/e2e_login.py)"] + StrawberryGQL["Strawberry GraphQL (apps/nest/api/internal/...)"] + DjangoAuth["Django Session Middleware & Authentication Engine"] + end + + subgraph StorageLayer ["4. Persistence & Cache Layer"] + Postgres[(PostgreSQL: Users, Roles, Programs)] + Redis[(Redis: Active Django Sessions)] + end + + PW -->|POST /e2e/login/| NextConfig + NextConfig -->|Proxy /e2e/login/| E2ELoginView + E2ELoginView -->|django.contrib.auth.login| DjangoAuth + DjangoAuth -->|Store Session ID| Redis + DjangoAuth -->|Query User| Postgres + E2ELoginView -->>|Set-Cookie: nest.session-id| PW + + PW -->|POST /graphql/ with nest.session-id| NextConfig + NextConfig -->|Proxy /graphql/| StrawberryGQL + StrawberryGQL -->|Verify Session & Role| DjangoAuth + StrawberryGQL -->|Fetch Data| Postgres + StrawberryGQL -->>|200 OK + Data| PW +``` + +--- + +## 2. Real Production Auth vs. E2E Challenge + +### Real Production Authentication Flow: +1. **User interaction:** User navigates to Nest and clicks *"Sign in with GitHub"*. +2. **OAuth Handshake:** `NextAuth` opens a GitHub OAuth popup $\rightarrow$ User inputs credentials $\rightarrow$ GitHub returns an OAuth `access_token`. +3. **Session Synchronization:** The frontend hook (`useDjangoSession`) executes the `githubAuth` GraphQL mutation, passing the GitHub `access_token` to Django. +4. **Backend Verification:** Django calls the GitHub API using that token to fetch verified email addresses and GitHub profile data. +5. **Django Session Creation:** Django finds or creates the `NestUser` in PostgreSQL and calls `django.contrib.auth.login(request, user)`. +6. **Cookie Delivery:** Django returns the session cookie: `nest.session-id`. Subsequent GraphQL requests send this cookie. + +### Why Real Auth Fails in Automated CI/E2E: +* Playwright test runners in GitHub Actions cannot solve GitHub Captchas, handle 2FA/MFA, or safely store real GitHub user passwords. +* External OAuth providers enforce strict rate-limiting on CI IP addresses. +* Downloading production database dumps (`nest.dump`) is slow, requires AWS S3 secrets, and breaks foreign key relationships over time. + +--- + +## 3. How Test Users Are Created + +Test fixtures are created deterministically in PostgreSQL by a dedicated Django management command: + +📁 `backend/src/apps/nest/management/commands/e2e_seed_users.py` + +### Step-by-Step Execution: +1. **Environment Gate:** Checks `if not settings.IS_E2E_ENVIRONMENT: raise CommandError(...)`. The command aborts instantly if run in local, staging, or production mode. +2. **Search Indexing Disabled:** Wraps all database operations in `with index.disable_indexing():`. This stops Django signals from attempting to send test accounts to Algolia search. +3. **Entity Creation:** + The command creates 3 deterministic test profiles: + * **`e2e-user`**: Base user with no mentorship association. + * **`e2e-mentor`**: GitHub User $\rightarrow$ Nest User $\rightarrow$ Linked `Mentor` row. + * **`e2e-mentee`**: GitHub User $\rightarrow$ Nest User $\rightarrow$ Linked `Mentee` row. +4. **Idempotency:** Uses `objects.get_or_create(...)` with fallback defaults. If the containers restart or the command runs multiple times, existing records are reused without creating duplicates or raising unique constraint errors. + +--- + +## 4. How the "Login Bypass" Works + +Instead of faking OAuth tokens or mocking network requests inside the browser, the POC creates a legitimate, server-side authenticated session via a test-only entry point. + +### Step 1: The Request +Playwright calls the helper `await loginAs(page, 'e2e-mentor')` in `e2e/helpers/loginAs.ts`. This sends: +```http +POST /e2e/login/ HTTP/1.1 +Host: localhost:3000 +Content-Type: application/json + +{"username": "e2e-mentor"} +``` + +### Step 2: Next.js Rewrite Proxy +In `frontend/next.config.ts`, when running in E2E mode (`NEXT_PUBLIC_E2E_BACKEND_BASE_URL` is set): +```typescript +{ source: '/e2e/login/', destination: `${backendBase}/e2e/login/` } +``` +Next.js proxies the request directly to Django on port 9000 without CORS friction. + +### Step 3: Django Native Login +In `backend/src/apps/nest/api/internal/views/e2e_login.py`: +1. Validates that `settings.IS_E2E_ENVIRONMENT` is `True`. +2. Reads the `username` from the JSON body. +3. Loads the user from PostgreSQL: `user = User.objects.get(username=username)`. +4. Executes native Django login: + ```python + login(request, user) + ``` +5. Django's `SessionMiddleware` creates a new session in Redis and adds a `Set-Cookie` header in the HTTP response: + ```http + HTTP/1.1 200 OK + Content-Type: application/json + Set-Cookie: nest.session-id=s%3A...; Path=/; HttpOnly; SameSite=Lax + + {"ok": true, "username": "e2e-mentor"} + ``` + +### Step 4: Playwright Context Capture +Playwright's `page.request` automatically synchronizes all `Set-Cookie` headers into the active `BrowserContext`. Any subsequent GraphQL request or page navigation made by Playwright carries the valid `nest.session-id` cookie. + +### Step 5: Protected API Execution +When Playwright requests protected GraphQL operations (such as `query { myPrograms { ... } }`): +* The request arrives at Django with the `nest.session-id` cookie. +* Django's `AuthenticationMiddleware` reads the cookie, finds the session in Redis, and attaches `request.user = `. +* Strawberry GraphQL / Django permissions check `info.context.request.user.is_authenticated`, which returns `True`. +* The query returns the authorized mentorship data. + +--- + +## 5. Security & Threat Analysis: Is This a Security Vulnerability? + +### Short Answer: +**No.** It is a standard, industry-recognized test fixture pattern when gated properly by environment flags. + +### Deep Threat Model: + +```mermaid +flowchart TD + Attacker["Malicious Actor on Production (https://nest.owasp.org)"] + + subgraph Defenses ["Multi-Layer Defense in Depth"] + Gate1["1. Base Settings Default: IS_E2E_ENVIRONMENT = False"] + Gate2["2. Django URLs: /e2e/login/ route NOT registered in production"] + Gate3["3. View Guard: e2e_login() raises Http404 if flag is False"] + Gate4["4. Next.js Rewrites: /e2e/login/ NOT proxied without E2E env var"] + Gate5["5. Seed Command: e2e_seed_users raises CommandError in prod"] + end + + Attacker -->|Attempt POST /e2e/login/| Gate1 + Gate1 --> Gate2 + Gate2 -->|Route Not Found| Blocked404["404 Not Found (Rejected before reaching application logic)"] +``` + +### Risk Matrix: + +| Potential Threat | Severity | Technical Control & Mitigation | Residual Risk | +| :--- | :---: | :--- | :---: | +| **Bypassing authentication in production** (e.g. logging into admin account) | 🔴 Critical (if exposed) | • `IS_E2E_ENVIRONMENT` is hardcoded to `False` in `settings/base.py` and only `True` in `settings/e2e.py`.
• `settings/urls.py` conditionally adds the URL: `if settings.IS_E2E_ENVIRONMENT: urlpatterns += [path("e2e/login/", e2e_login)]`. In production, the route does not exist.
• `e2e_login` view explicitly checks `if not settings.IS_E2E_ENVIRONMENT: raise Http404`. | 🟢 **None** | +| **CSRF exploitation** | 🟡 Medium | `@csrf_exempt` is attached only to `/e2e/login/`. Because the endpoint does not exist in production, CSRF cannot be exploited. | 🟢 **None** | +| **Production database poisoning** | 🟡 Medium | `e2e_seed_users` checks `IS_E2E_ENVIRONMENT` and raises `CommandError` outside the e2e environment. | 🟢 **None** | +| **Algolia search pollution** | 🟢 Low | `index.disable_indexing()` prevents fake test users from being pushed to Algolia search indices. | 🟢 **None** | + +--- + +## 6. Current Scope vs. Future Expansion + +| Feature Area | Current POC State | Future Enhancement | +| :--- | :--- | :--- | +| **Authenticated GraphQL Queries** | ✅ Fully tested via Django session (`nest.session-id`). | Expand test coverage to mutative operations (create program, apply as mentee). | +| **Unauthenticated Redirects** | ✅ Verified (`/my/mentorship` $\rightarrow$ `/auth/login`). | Add assertions for expired session handling. | +| **Authenticated UI Page Rendering** | ⚠️ Not included in this first cut. | To test `/my/mentorship` UI components in a browser, NextAuth JWT (`next-auth.session-token`) must also be generated/injected alongside the Django session cookie. | +| **Role Matrix Coverage** | ✅ Mentor & Mentee seeded. | Add `Project Leader` user seeding to test program creation permissions. | diff --git a/e2e/helpers/loginAs.ts b/e2e/helpers/loginAs.ts new file mode 100644 index 0000000000..a834db2515 --- /dev/null +++ b/e2e/helpers/loginAs.ts @@ -0,0 +1,11 @@ +import { Page } from '@playwright/test' + +export async function loginAs(page: Page, username: string) { + const response = await page.request.post('/e2e/login/', { + data: { username }, + headers: { 'Content-Type': 'application/json' }, + }) + if (!response.ok()) { + throw new Error(`e2e login failed: ${response.status()} ${await response.text()}`) + } +} diff --git a/e2e/make/test.mk b/e2e/make/test.mk index dfbd574106..99f013961c 100644 --- a/e2e/make/test.mk +++ b/e2e/make/test.mk @@ -1,9 +1,12 @@ -.PHONY: test-e2e e2e-load-data e2e-db-init e2e-test e2e-test-no-db-init \ - e2e-test-run-backend e2e-test-ui e2e-test-ui-no-db-init +.PHONY: test-e2e test-e2e-protected e2e-load-data e2e-db-init e2e-test \ + e2e-test-no-db-init e2e-test-protected e2e-test-run-backend e2e-test-ui \ + e2e-test-ui-no-db-init test-e2e: ## Run e2e tests @$(MAKE) e2e-test +test-e2e-protected: e2e-test-protected ## Run e2e tests for protected routes (no dump required) + # Implementation targets. e2e-db-init: @@ -46,6 +49,21 @@ e2e-test-no-db-init: backend cache db frontend e2e-tests \ --remove-orphans +e2e-test-protected: + @DOCKER_BUILDKIT=1 docker compose \ + --project-name nest-e2e \ + -f docker-compose/e2e/compose.yaml build -q backend frontend e2e-tests \ + 1>/dev/null + @DOCKER_BUILDKIT=1 E2E_TEST_COMMAND="pnpm run test:e2e:protected" docker compose \ + --project-name nest-e2e \ + -f docker-compose/e2e/compose.yaml up \ + --abort-on-container-exit \ + --attach e2e-tests \ + --no-build \ + --quiet-pull \ + backend cache db frontend e2e-tests \ + --remove-orphans + e2e-test-run-backend: @DOCKER_BUILDKIT=1 \ docker compose --project-name nest-e2e -f docker-compose/e2e/compose.yaml up --build --remove-orphans --abort-on-container-exit backend db cache diff --git a/e2e/package.json b/e2e/package.json index 718916640b..277418235d 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -12,6 +12,7 @@ "private": true, "scripts": { "test:e2e": "playwright test", + "test:e2e:protected": "playwright test pages/protected", "test:e2e:ui": "playwright test --ui-host=0.0.0.0 --ui-port=3800" } } diff --git a/e2e/pages/protected/MyMentorship.spec.ts b/e2e/pages/protected/MyMentorship.spec.ts new file mode 100644 index 0000000000..0e67cd2daa --- /dev/null +++ b/e2e/pages/protected/MyMentorship.spec.ts @@ -0,0 +1,37 @@ +import { loginAs } from '@e2e/helpers/loginAs' +import { test, expect } from '@playwright/test' + +const MY_PROGRAMS_QUERY = ` + query { + myPrograms { + currentPage + totalPages + programs { key } + } + } +` + +test.describe('My Mentorship', () => { + test('redirects unauthenticated users to login', async ({ page }) => { + await page.goto('/my/mentorship', { waitUntil: 'domcontentloaded' }) + await expect(page).toHaveURL(/\/auth\/login/) + }) + + test('allows myPrograms GraphQL after e2e login', async ({ page }) => { + await loginAs(page, 'e2e-mentor') + + const csrfResponse = await page.request.get('/csrf/') + const { csrftoken } = await csrfResponse.json() + const response = await page.request.post('/graphql/', { + data: { query: MY_PROGRAMS_QUERY }, + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrftoken, + }, + }) + const body = await response.json() + + expect(body.errors).toBeUndefined() + expect(body.data.myPrograms).toBeTruthy() + }) +}) diff --git a/frontend/next.config.ts b/frontend/next.config.ts index c659833ffd..cb1d749287 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -74,6 +74,7 @@ const nextConfig: NextConfig = { const backendBase = process.env.NEXT_PUBLIC_E2E_BACKEND_BASE_URL return [ { source: '/csrf/', destination: `${backendBase}/csrf/` }, + { source: '/e2e/login/', destination: `${backendBase}/e2e/login/` }, { source: '/graphql/', destination: `${backendBase}/graphql/` }, ] } From e9e4f38ddc184725277ffec18921476bf5fba4e7 Mon Sep 17 00:00:00 2001 From: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> Date: Thu, 20 Aug 2026 00:02:46 +0530 Subject: [PATCH 02/20] add e2e page login so protected mentorship UI can load Mint an e2e-only NextAuth cookie (no GitHub token) on top of the Django session so /my/mentorship renders without OAuth. Co-authored-by: Cursor Signed-off-by: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> Co-authored-by: Cursor --- e2e/helpers/loginAs.ts | 11 ++++++ e2e/pages/protected/MyMentorship.spec.ts | 10 +++++- frontend/src/app/api/e2e/session/route.ts | 42 +++++++++++++++++++++++ 3 files changed, 62 insertions(+), 1 deletion(-) create mode 100644 frontend/src/app/api/e2e/session/route.ts diff --git a/e2e/helpers/loginAs.ts b/e2e/helpers/loginAs.ts index a834db2515..35833697a9 100644 --- a/e2e/helpers/loginAs.ts +++ b/e2e/helpers/loginAs.ts @@ -9,3 +9,14 @@ export async function loginAs(page: Page, username: string) { throw new Error(`e2e login failed: ${response.status()} ${await response.text()}`) } } + +export async function loginAsPage(page: Page, username: string) { + await loginAs(page, username) + const response = await page.request.post('/api/e2e/session', { + data: { username }, + headers: { 'Content-Type': 'application/json' }, + }) + if (!response.ok()) { + throw new Error(`e2e nextauth session failed: ${response.status()} ${await response.text()}`) + } +} diff --git a/e2e/pages/protected/MyMentorship.spec.ts b/e2e/pages/protected/MyMentorship.spec.ts index 0e67cd2daa..8777fa9615 100644 --- a/e2e/pages/protected/MyMentorship.spec.ts +++ b/e2e/pages/protected/MyMentorship.spec.ts @@ -1,4 +1,4 @@ -import { loginAs } from '@e2e/helpers/loginAs' +import { loginAs, loginAsPage } from '@e2e/helpers/loginAs' import { test, expect } from '@playwright/test' const MY_PROGRAMS_QUERY = ` @@ -34,4 +34,12 @@ test.describe('My Mentorship', () => { expect(body.errors).toBeUndefined() expect(body.data.myPrograms).toBeTruthy() }) + + test('renders My Mentorship after e2e page login', async ({ page }) => { + await loginAsPage(page, 'e2e-mentor') + await page.goto('/my/mentorship', { waitUntil: 'domcontentloaded' }) + await expect(page).not.toHaveURL(/\/auth\/login/) + await expect(page.getByRole('heading', { name: 'My Mentorship' })).toBeVisible() + await expect(page.getByText('No programs found')).toBeVisible() + }) }) diff --git a/frontend/src/app/api/e2e/session/route.ts b/frontend/src/app/api/e2e/session/route.ts new file mode 100644 index 0000000000..28453395d0 --- /dev/null +++ b/frontend/src/app/api/e2e/session/route.ts @@ -0,0 +1,42 @@ +import { encode } from 'next-auth/jwt' +import { NextResponse } from 'next/server' + +export async function POST(request: Request) { + if (!process.env.NEXT_PUBLIC_E2E_BACKEND_BASE_URL) { + return new NextResponse(null, { status: 404 }) + } + + let username = '' + try { + const body = (await request.json()) as { username?: string } + username = (body.username ?? '').trim() + } catch { + return NextResponse.json({ ok: false }, { status: 400 }) + } + + if (!username) { + return NextResponse.json({ ok: false }, { status: 400 }) + } + + const token = await encode({ + secret: process.env.NEXTAUTH_SECRET ?? '', + token: { + email: `${username}@example.com`, + isLeader: false, + isMentee: username === 'e2e-mentee', + isMentor: username === 'e2e-mentor', + login: username, + name: username, + sub: username, + }, + }) + + const response = NextResponse.json({ ok: true }) + response.cookies.set('next-auth.session-token', token, { + httpOnly: true, + path: '/', + sameSite: 'lax', + secure: false, + }) + return response +} From 0081d2897e527e6098c7c61bf25a8f0d466c43bd Mon Sep 17 00:00:00 2001 From: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:19:06 +0530 Subject: [PATCH 03/20] add tests Signed-off-by: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> --- .../commands/e2e_seed_users_test.py | 2 +- e2e/PROTECTED_TESTS_EXPLAINED.md | 179 ------------------ e2e/helpers/loginAs.ts | 25 ++- e2e/pages/protected/MyMentorship.spec.ts | 16 +- frontend/src/app/api/e2e/session/route.ts | 7 +- 5 files changed, 44 insertions(+), 185 deletions(-) delete mode 100644 e2e/PROTECTED_TESTS_EXPLAINED.md diff --git a/backend/tests/unit/apps/nest/management/commands/e2e_seed_users_test.py b/backend/tests/unit/apps/nest/management/commands/e2e_seed_users_test.py index 8914bc5017..86978a033c 100644 --- a/backend/tests/unit/apps/nest/management/commands/e2e_seed_users_test.py +++ b/backend/tests/unit/apps/nest/management/commands/e2e_seed_users_test.py @@ -4,7 +4,7 @@ from django.core.management import call_command from django.core.management.base import CommandError -from apps.nest.management.commands.e2e_seed_users import Command, E2E_USERS +from apps.nest.management.commands.e2e_seed_users import E2E_USERS, Command class TestE2ESeedUsersCommand: diff --git a/e2e/PROTECTED_TESTS_EXPLAINED.md b/e2e/PROTECTED_TESTS_EXPLAINED.md deleted file mode 100644 index c28bcdbcc5..0000000000 --- a/e2e/PROTECTED_TESTS_EXPLAINED.md +++ /dev/null @@ -1,179 +0,0 @@ -# Protected Routes & E2E Testing Architecture - -This document explains in detail how end-to-end (E2E) testing works on authenticated/protected routes in OWASP Nest, how test users are seeded, how authentication is simulated ("bypassed") without GitHub OAuth, and a thorough security analysis of whether this represents a vulnerability. - ---- - -## 1. System Architecture & Layers Involved - -OWASP Nest is a decoupled full-stack application consisting of multiple distinct layers: - -```mermaid -flowchart TD - subgraph TestRunner ["1. Test Orchestration Layer"] - PW["Playwright (e2e-tests container)"] - end - - subgraph FrontendLayer ["2. Frontend Layer (Next.js - Port 3000)"] - NextProxy["Next.js Route Middleware (src/proxy.ts)"] - NextConfig["Next.js Rewrites (next.config.ts)"] - NextAuth["NextAuth JWT Session Handler"] - end - - subgraph BackendLayer ["3. Backend Layer (Django - Port 9000)"] - Urls["URL Router (settings/urls.py)"] - E2ELoginView["E2E Login View (apps/nest/api/internal/views/e2e_login.py)"] - StrawberryGQL["Strawberry GraphQL (apps/nest/api/internal/...)"] - DjangoAuth["Django Session Middleware & Authentication Engine"] - end - - subgraph StorageLayer ["4. Persistence & Cache Layer"] - Postgres[(PostgreSQL: Users, Roles, Programs)] - Redis[(Redis: Active Django Sessions)] - end - - PW -->|POST /e2e/login/| NextConfig - NextConfig -->|Proxy /e2e/login/| E2ELoginView - E2ELoginView -->|django.contrib.auth.login| DjangoAuth - DjangoAuth -->|Store Session ID| Redis - DjangoAuth -->|Query User| Postgres - E2ELoginView -->>|Set-Cookie: nest.session-id| PW - - PW -->|POST /graphql/ with nest.session-id| NextConfig - NextConfig -->|Proxy /graphql/| StrawberryGQL - StrawberryGQL -->|Verify Session & Role| DjangoAuth - StrawberryGQL -->|Fetch Data| Postgres - StrawberryGQL -->>|200 OK + Data| PW -``` - ---- - -## 2. Real Production Auth vs. E2E Challenge - -### Real Production Authentication Flow: -1. **User interaction:** User navigates to Nest and clicks *"Sign in with GitHub"*. -2. **OAuth Handshake:** `NextAuth` opens a GitHub OAuth popup $\rightarrow$ User inputs credentials $\rightarrow$ GitHub returns an OAuth `access_token`. -3. **Session Synchronization:** The frontend hook (`useDjangoSession`) executes the `githubAuth` GraphQL mutation, passing the GitHub `access_token` to Django. -4. **Backend Verification:** Django calls the GitHub API using that token to fetch verified email addresses and GitHub profile data. -5. **Django Session Creation:** Django finds or creates the `NestUser` in PostgreSQL and calls `django.contrib.auth.login(request, user)`. -6. **Cookie Delivery:** Django returns the session cookie: `nest.session-id`. Subsequent GraphQL requests send this cookie. - -### Why Real Auth Fails in Automated CI/E2E: -* Playwright test runners in GitHub Actions cannot solve GitHub Captchas, handle 2FA/MFA, or safely store real GitHub user passwords. -* External OAuth providers enforce strict rate-limiting on CI IP addresses. -* Downloading production database dumps (`nest.dump`) is slow, requires AWS S3 secrets, and breaks foreign key relationships over time. - ---- - -## 3. How Test Users Are Created - -Test fixtures are created deterministically in PostgreSQL by a dedicated Django management command: - -📁 `backend/src/apps/nest/management/commands/e2e_seed_users.py` - -### Step-by-Step Execution: -1. **Environment Gate:** Checks `if not settings.IS_E2E_ENVIRONMENT: raise CommandError(...)`. The command aborts instantly if run in local, staging, or production mode. -2. **Search Indexing Disabled:** Wraps all database operations in `with index.disable_indexing():`. This stops Django signals from attempting to send test accounts to Algolia search. -3. **Entity Creation:** - The command creates 3 deterministic test profiles: - * **`e2e-user`**: Base user with no mentorship association. - * **`e2e-mentor`**: GitHub User $\rightarrow$ Nest User $\rightarrow$ Linked `Mentor` row. - * **`e2e-mentee`**: GitHub User $\rightarrow$ Nest User $\rightarrow$ Linked `Mentee` row. -4. **Idempotency:** Uses `objects.get_or_create(...)` with fallback defaults. If the containers restart or the command runs multiple times, existing records are reused without creating duplicates or raising unique constraint errors. - ---- - -## 4. How the "Login Bypass" Works - -Instead of faking OAuth tokens or mocking network requests inside the browser, the POC creates a legitimate, server-side authenticated session via a test-only entry point. - -### Step 1: The Request -Playwright calls the helper `await loginAs(page, 'e2e-mentor')` in `e2e/helpers/loginAs.ts`. This sends: -```http -POST /e2e/login/ HTTP/1.1 -Host: localhost:3000 -Content-Type: application/json - -{"username": "e2e-mentor"} -``` - -### Step 2: Next.js Rewrite Proxy -In `frontend/next.config.ts`, when running in E2E mode (`NEXT_PUBLIC_E2E_BACKEND_BASE_URL` is set): -```typescript -{ source: '/e2e/login/', destination: `${backendBase}/e2e/login/` } -``` -Next.js proxies the request directly to Django on port 9000 without CORS friction. - -### Step 3: Django Native Login -In `backend/src/apps/nest/api/internal/views/e2e_login.py`: -1. Validates that `settings.IS_E2E_ENVIRONMENT` is `True`. -2. Reads the `username` from the JSON body. -3. Loads the user from PostgreSQL: `user = User.objects.get(username=username)`. -4. Executes native Django login: - ```python - login(request, user) - ``` -5. Django's `SessionMiddleware` creates a new session in Redis and adds a `Set-Cookie` header in the HTTP response: - ```http - HTTP/1.1 200 OK - Content-Type: application/json - Set-Cookie: nest.session-id=s%3A...; Path=/; HttpOnly; SameSite=Lax - - {"ok": true, "username": "e2e-mentor"} - ``` - -### Step 4: Playwright Context Capture -Playwright's `page.request` automatically synchronizes all `Set-Cookie` headers into the active `BrowserContext`. Any subsequent GraphQL request or page navigation made by Playwright carries the valid `nest.session-id` cookie. - -### Step 5: Protected API Execution -When Playwright requests protected GraphQL operations (such as `query { myPrograms { ... } }`): -* The request arrives at Django with the `nest.session-id` cookie. -* Django's `AuthenticationMiddleware` reads the cookie, finds the session in Redis, and attaches `request.user = `. -* Strawberry GraphQL / Django permissions check `info.context.request.user.is_authenticated`, which returns `True`. -* The query returns the authorized mentorship data. - ---- - -## 5. Security & Threat Analysis: Is This a Security Vulnerability? - -### Short Answer: -**No.** It is a standard, industry-recognized test fixture pattern when gated properly by environment flags. - -### Deep Threat Model: - -```mermaid -flowchart TD - Attacker["Malicious Actor on Production (https://nest.owasp.org)"] - - subgraph Defenses ["Multi-Layer Defense in Depth"] - Gate1["1. Base Settings Default: IS_E2E_ENVIRONMENT = False"] - Gate2["2. Django URLs: /e2e/login/ route NOT registered in production"] - Gate3["3. View Guard: e2e_login() raises Http404 if flag is False"] - Gate4["4. Next.js Rewrites: /e2e/login/ NOT proxied without E2E env var"] - Gate5["5. Seed Command: e2e_seed_users raises CommandError in prod"] - end - - Attacker -->|Attempt POST /e2e/login/| Gate1 - Gate1 --> Gate2 - Gate2 -->|Route Not Found| Blocked404["404 Not Found (Rejected before reaching application logic)"] -``` - -### Risk Matrix: - -| Potential Threat | Severity | Technical Control & Mitigation | Residual Risk | -| :--- | :---: | :--- | :---: | -| **Bypassing authentication in production** (e.g. logging into admin account) | 🔴 Critical (if exposed) | • `IS_E2E_ENVIRONMENT` is hardcoded to `False` in `settings/base.py` and only `True` in `settings/e2e.py`.
• `settings/urls.py` conditionally adds the URL: `if settings.IS_E2E_ENVIRONMENT: urlpatterns += [path("e2e/login/", e2e_login)]`. In production, the route does not exist.
• `e2e_login` view explicitly checks `if not settings.IS_E2E_ENVIRONMENT: raise Http404`. | 🟢 **None** | -| **CSRF exploitation** | 🟡 Medium | `@csrf_exempt` is attached only to `/e2e/login/`. Because the endpoint does not exist in production, CSRF cannot be exploited. | 🟢 **None** | -| **Production database poisoning** | 🟡 Medium | `e2e_seed_users` checks `IS_E2E_ENVIRONMENT` and raises `CommandError` outside the e2e environment. | 🟢 **None** | -| **Algolia search pollution** | 🟢 Low | `index.disable_indexing()` prevents fake test users from being pushed to Algolia search indices. | 🟢 **None** | - ---- - -## 6. Current Scope vs. Future Expansion - -| Feature Area | Current POC State | Future Enhancement | -| :--- | :--- | :--- | -| **Authenticated GraphQL Queries** | ✅ Fully tested via Django session (`nest.session-id`). | Expand test coverage to mutative operations (create program, apply as mentee). | -| **Unauthenticated Redirects** | ✅ Verified (`/my/mentorship` $\rightarrow$ `/auth/login`). | Add assertions for expired session handling. | -| **Authenticated UI Page Rendering** | ⚠️ Not included in this first cut. | To test `/my/mentorship` UI components in a browser, NextAuth JWT (`next-auth.session-token`) must also be generated/injected alongside the Django session cookie. | -| **Role Matrix Coverage** | ✅ Mentor & Mentee seeded. | Add `Project Leader` user seeding to test program creation permissions. | diff --git a/e2e/helpers/loginAs.ts b/e2e/helpers/loginAs.ts index 35833697a9..ce1607bfc0 100644 --- a/e2e/helpers/loginAs.ts +++ b/e2e/helpers/loginAs.ts @@ -10,13 +10,32 @@ export async function loginAs(page: Page, username: string) { } } -export async function loginAsPage(page: Page, username: string) { - await loginAs(page, username) +export async function setNextAuthSession( + page: Page, + username: string, + options?: { maxAge?: number } +) { const response = await page.request.post('/api/e2e/session', { - data: { username }, + data: { username, ...(options?.maxAge === undefined ? {} : { maxAge: options.maxAge }) }, headers: { 'Content-Type': 'application/json' }, }) if (!response.ok()) { throw new Error(`e2e nextauth session failed: ${response.status()} ${await response.text()}`) } } + +export async function setInvalidNextAuthSession(page: Page) { + await page.context().addCookies([ + { + name: 'next-auth.session-token', + path: '/', + url: process.env.FRONTEND_URL || 'http://localhost:3000', + value: 'invalid-session-token', + }, + ]) +} + +export async function loginAsPage(page: Page, username: string) { + await loginAs(page, username) + await setNextAuthSession(page, username) +} diff --git a/e2e/pages/protected/MyMentorship.spec.ts b/e2e/pages/protected/MyMentorship.spec.ts index 8777fa9615..d80263e54d 100644 --- a/e2e/pages/protected/MyMentorship.spec.ts +++ b/e2e/pages/protected/MyMentorship.spec.ts @@ -1,4 +1,4 @@ -import { loginAs, loginAsPage } from '@e2e/helpers/loginAs' +import { loginAs, loginAsPage, setInvalidNextAuthSession, setNextAuthSession } from '@e2e/helpers/loginAs' import { test, expect } from '@playwright/test' const MY_PROGRAMS_QUERY = ` @@ -42,4 +42,18 @@ test.describe('My Mentorship', () => { await expect(page.getByRole('heading', { name: 'My Mentorship' })).toBeVisible() await expect(page.getByText('No programs found')).toBeVisible() }) + + test('redirects when NextAuth session cookie is invalid', async ({ page }) => { + await loginAs(page, 'e2e-mentor') + await setInvalidNextAuthSession(page) + await page.goto('/my/mentorship', { waitUntil: 'domcontentloaded' }) + await expect(page).toHaveURL(/\/auth\/login/) + }) + + test('redirects when NextAuth session cookie is expired', async ({ page }) => { + await loginAs(page, 'e2e-mentor') + await setNextAuthSession(page, 'e2e-mentor', { maxAge: 0 }) + await page.goto('/my/mentorship', { waitUntil: 'domcontentloaded' }) + await expect(page).toHaveURL(/\/auth\/login/) + }) }) diff --git a/frontend/src/app/api/e2e/session/route.ts b/frontend/src/app/api/e2e/session/route.ts index 28453395d0..137e9b4079 100644 --- a/frontend/src/app/api/e2e/session/route.ts +++ b/frontend/src/app/api/e2e/session/route.ts @@ -7,9 +7,13 @@ export async function POST(request: Request) { } let username = '' + let maxAge: number | undefined try { - const body = (await request.json()) as { username?: string } + const body = (await request.json()) as { maxAge?: number; username?: string } username = (body.username ?? '').trim() + if (typeof body.maxAge === 'number' && Number.isFinite(body.maxAge)) { + maxAge = body.maxAge + } } catch { return NextResponse.json({ ok: false }, { status: 400 }) } @@ -29,6 +33,7 @@ export async function POST(request: Request) { name: username, sub: username, }, + ...(maxAge === undefined ? {} : { maxAge }), }) const response = NextResponse.json({ ok: true }) From 5e405e49003f719795789d1ca961fdc1c648f777 Mon Sep 17 00:00:00 2001 From: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:27:20 +0530 Subject: [PATCH 04/20] change comment Signed-off-by: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> --- backend/src/apps/nest/api/internal/views/e2e_login.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/apps/nest/api/internal/views/e2e_login.py b/backend/src/apps/nest/api/internal/views/e2e_login.py index 88110015e6..acc1a7cba1 100644 --- a/backend/src/apps/nest/api/internal/views/e2e_login.py +++ b/backend/src/apps/nest/api/internal/views/e2e_login.py @@ -11,7 +11,7 @@ from apps.nest.models import User -@csrf_exempt # NOSEMGREP: python.django.security.audit.csrf-exempt.no-csrf-exempt # NOSONAR +@csrf_exempt # reject POSTs that have no CSRF cookie @require_POST def e2e_login(request: HttpRequest) -> JsonResponse: """Log in a seeded e2e user and set the Django session cookie.""" From 4bd6a4922374a318a03ac668b983c6dd0ead0ccd Mon Sep 17 00:00:00 2001 From: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:28:04 +0530 Subject: [PATCH 05/20] make check test err Signed-off-by: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> --- e2e/helpers/loginAs.ts | 31 +++++++------- e2e/pages/protected/MyMentorship.spec.ts | 51 ++++++++++++++---------- 2 files changed, 45 insertions(+), 37 deletions(-) diff --git a/e2e/helpers/loginAs.ts b/e2e/helpers/loginAs.ts index ce1607bfc0..8b0ddf8d03 100644 --- a/e2e/helpers/loginAs.ts +++ b/e2e/helpers/loginAs.ts @@ -1,27 +1,26 @@ import { Page } from '@playwright/test' -export async function loginAs(page: Page, username: string) { - const response = await page.request.post('/e2e/login/', { - data: { username }, +async function postJson(page: Page, url: string, data: object, errorLabel: string) { + const response = await page.request.post(url, { + data, headers: { 'Content-Type': 'application/json' }, }) if (!response.ok()) { - throw new Error(`e2e login failed: ${response.status()} ${await response.text()}`) + throw new Error(`${errorLabel}: ${response.status()} ${await response.text()}`) } } -export async function setNextAuthSession( - page: Page, - username: string, - options?: { maxAge?: number } -) { - const response = await page.request.post('/api/e2e/session', { - data: { username, ...(options?.maxAge === undefined ? {} : { maxAge: options.maxAge }) }, - headers: { 'Content-Type': 'application/json' }, - }) - if (!response.ok()) { - throw new Error(`e2e nextauth session failed: ${response.status()} ${await response.text()}`) - } +export async function loginAs(page: Page, username: string) { + await postJson(page, '/e2e/login/', { username }, 'e2e login failed') +} + +export async function setNextAuthSession(page: Page, username: string, maxAge?: number) { + await postJson( + page, + '/api/e2e/session', + maxAge === undefined ? { username } : { username, maxAge }, + 'e2e nextauth session failed' + ) } export async function setInvalidNextAuthSession(page: Page) { diff --git a/e2e/pages/protected/MyMentorship.spec.ts b/e2e/pages/protected/MyMentorship.spec.ts index d80263e54d..9e7ab1bd55 100644 --- a/e2e/pages/protected/MyMentorship.spec.ts +++ b/e2e/pages/protected/MyMentorship.spec.ts @@ -1,5 +1,13 @@ -import { loginAs, loginAsPage, setInvalidNextAuthSession, setNextAuthSession } from '@e2e/helpers/loginAs' +import { + loginAs, + loginAsPage, + setInvalidNextAuthSession, + setNextAuthSession, +} from '@e2e/helpers/loginAs' import { test, expect } from '@playwright/test' +import { Page } from '@playwright/test' + +const USER = 'e2e-mentor' const MY_PROGRAMS_QUERY = ` query { @@ -11,48 +19,49 @@ const MY_PROGRAMS_QUERY = ` } ` -test.describe('My Mentorship', () => { +async function postGraphql(page: Page, query: string) { + const csrfResponse = await page.request.get('/csrf/') + const { csrftoken } = await csrfResponse.json() + const response = await page.request.post('/graphql/', { + data: { query }, + headers: { + 'Content-Type': 'application/json', + 'X-CSRFToken': csrftoken, + }, + }) + return response.json() +} + +test.describe('My Mentorship Page', () => { test('redirects unauthenticated users to login', async ({ page }) => { await page.goto('/my/mentorship', { waitUntil: 'domcontentloaded' }) await expect(page).toHaveURL(/\/auth\/login/) }) test('allows myPrograms GraphQL after e2e login', async ({ page }) => { - await loginAs(page, 'e2e-mentor') - - const csrfResponse = await page.request.get('/csrf/') - const { csrftoken } = await csrfResponse.json() - const response = await page.request.post('/graphql/', { - data: { query: MY_PROGRAMS_QUERY }, - headers: { - 'Content-Type': 'application/json', - 'X-CSRFToken': csrftoken, - }, - }) - const body = await response.json() - + await loginAs(page, USER) + const body = await postGraphql(page, MY_PROGRAMS_QUERY) expect(body.errors).toBeUndefined() expect(body.data.myPrograms).toBeTruthy() }) - test('renders My Mentorship after e2e page login', async ({ page }) => { - await loginAsPage(page, 'e2e-mentor') + test('renders heading after e2e page login', async ({ page }) => { + await loginAsPage(page, USER) await page.goto('/my/mentorship', { waitUntil: 'domcontentloaded' }) - await expect(page).not.toHaveURL(/\/auth\/login/) await expect(page.getByRole('heading', { name: 'My Mentorship' })).toBeVisible() await expect(page.getByText('No programs found')).toBeVisible() }) test('redirects when NextAuth session cookie is invalid', async ({ page }) => { - await loginAs(page, 'e2e-mentor') + await loginAs(page, USER) await setInvalidNextAuthSession(page) await page.goto('/my/mentorship', { waitUntil: 'domcontentloaded' }) await expect(page).toHaveURL(/\/auth\/login/) }) test('redirects when NextAuth session cookie is expired', async ({ page }) => { - await loginAs(page, 'e2e-mentor') - await setNextAuthSession(page, 'e2e-mentor', { maxAge: 0 }) + await loginAs(page, USER) + await setNextAuthSession(page, USER, 0) await page.goto('/my/mentorship', { waitUntil: 'domcontentloaded' }) await expect(page).toHaveURL(/\/auth\/login/) }) From 21fcd5092bd2523770b59690faa5811ccecee9ed Mon Sep 17 00:00:00 2001 From: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:29:18 +0530 Subject: [PATCH 06/20] fix e2e tests Signed-off-by: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> --- e2e/pages/protected/MyMentorship.spec.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/e2e/pages/protected/MyMentorship.spec.ts b/e2e/pages/protected/MyMentorship.spec.ts index 9e7ab1bd55..e0beeb3125 100644 --- a/e2e/pages/protected/MyMentorship.spec.ts +++ b/e2e/pages/protected/MyMentorship.spec.ts @@ -4,8 +4,7 @@ import { setInvalidNextAuthSession, setNextAuthSession, } from '@e2e/helpers/loginAs' -import { test, expect } from '@playwright/test' -import { Page } from '@playwright/test' +import { expect, Page, test } from '@playwright/test' const USER = 'e2e-mentor' From 605eab250730fc0deb88fca5590a725ca06d6b6c Mon Sep 17 00:00:00 2001 From: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:32:59 +0530 Subject: [PATCH 07/20] fix e2e tests Signed-off-by: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> --- .../apps/nest/api/internal/views/e2e_login_test.py | 10 +++++----- .../nest/management/commands/e2e_seed_users_test.py | 4 ++-- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/backend/tests/unit/apps/nest/api/internal/views/e2e_login_test.py b/backend/tests/unit/apps/nest/api/internal/views/e2e_login_test.py index 283dc1d880..4c3df3f9ec 100644 --- a/backend/tests/unit/apps/nest/api/internal/views/e2e_login_test.py +++ b/backend/tests/unit/apps/nest/api/internal/views/e2e_login_test.py @@ -24,7 +24,7 @@ def test_returns_404_outside_e2e(self): with ( patch( "apps.nest.api.internal.views.e2e_login.settings.IS_E2E_ENVIRONMENT", - False, + new=False, ), pytest.raises(Http404), ): @@ -33,7 +33,7 @@ def test_returns_404_outside_e2e(self): def test_returns_400_for_invalid_json(self): with patch( "apps.nest.api.internal.views.e2e_login.settings.IS_E2E_ENVIRONMENT", - True, + new=True, ): response = e2e_login(_post("{")) @@ -43,7 +43,7 @@ def test_returns_400_for_invalid_json(self): def test_returns_400_without_username(self): with patch( "apps.nest.api.internal.views.e2e_login.settings.IS_E2E_ENVIRONMENT", - True, + new=True, ): response = e2e_login(_post({})) @@ -54,7 +54,7 @@ def test_returns_404_for_unknown_user(self): with ( patch( "apps.nest.api.internal.views.e2e_login.settings.IS_E2E_ENVIRONMENT", - True, + new=True, ), patch( "apps.nest.api.internal.views.e2e_login.User.objects.get", @@ -71,7 +71,7 @@ def test_logs_in_user(self): with ( patch( "apps.nest.api.internal.views.e2e_login.settings.IS_E2E_ENVIRONMENT", - True, + new=True, ), patch( "apps.nest.api.internal.views.e2e_login.User.objects.get", diff --git a/backend/tests/unit/apps/nest/management/commands/e2e_seed_users_test.py b/backend/tests/unit/apps/nest/management/commands/e2e_seed_users_test.py index 86978a033c..ebd9fb131b 100644 --- a/backend/tests/unit/apps/nest/management/commands/e2e_seed_users_test.py +++ b/backend/tests/unit/apps/nest/management/commands/e2e_seed_users_test.py @@ -20,7 +20,7 @@ def test_requires_e2e_environment(self): with ( patch( "apps.nest.management.commands.e2e_seed_users.settings.IS_E2E_ENVIRONMENT", - False, + new=False, ), pytest.raises(CommandError, match="e2e environment"), ): @@ -41,7 +41,7 @@ def test_creates_users( with patch( "apps.nest.management.commands.e2e_seed_users.settings.IS_E2E_ENVIRONMENT", - True, + new=True, ): call_command("e2e_seed_users") From fdcec7ed8887cc9e68cf91cc88127e468b7ed223 Mon Sep 17 00:00:00 2001 From: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:35:45 +0530 Subject: [PATCH 08/20] fix import Signed-off-by: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> --- frontend/src/app/api/e2e/session/route.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/frontend/src/app/api/e2e/session/route.ts b/frontend/src/app/api/e2e/session/route.ts index 137e9b4079..06a6fb2063 100644 --- a/frontend/src/app/api/e2e/session/route.ts +++ b/frontend/src/app/api/e2e/session/route.ts @@ -1,5 +1,5 @@ -import { encode } from 'next-auth/jwt' import { NextResponse } from 'next/server' +import { encode } from 'next-auth/jwt' export async function POST(request: Request) { if (!process.env.NEXT_PUBLIC_E2E_BACKEND_BASE_URL) { From 514e0ececa26a52f5b21e00a6af4f7d21522f73c Mon Sep 17 00:00:00 2001 From: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> Date: Thu, 20 Aug 2026 19:53:54 +0530 Subject: [PATCH 09/20] fix test suite Signed-off-by: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> --- docker-compose/e2e/compose.yaml | 4 +++- e2e/make/test.mk | 2 +- frontend/src/app/api/e2e/session/route.ts | 13 ++++++------- 3 files changed, 10 insertions(+), 9 deletions(-) diff --git a/docker-compose/e2e/compose.yaml b/docker-compose/e2e/compose.yaml index 84d07ab858..987c0dfa1d 100644 --- a/docker-compose/e2e/compose.yaml +++ b/docker-compose/e2e/compose.yaml @@ -7,7 +7,7 @@ services: command: > sh -c ' python manage.py migrate && - python manage.py e2e_seed_users && + if [ "$${E2E_SKIP_SEED:-0}" != "1" ]; then python manage.py e2e_seed_users; fi && gunicorn wsgi:application --bind 0.0.0.0:9000 ' container_name: e2e-nest-backend @@ -16,6 +16,8 @@ services: condition: service_healthy cache: condition: service_healthy + environment: + E2E_SKIP_SEED: ${E2E_SKIP_SEED:-0} env_file: ../../backend/.env.e2e-tests healthcheck: interval: 10s diff --git a/e2e/make/test.mk b/e2e/make/test.mk index 99f013961c..afb07cff4e 100644 --- a/e2e/make/test.mk +++ b/e2e/make/test.mk @@ -17,7 +17,7 @@ e2e-db-init: --project-name nest-e2e \ -f docker-compose/e2e/compose.yaml build -q backend \ 1>/dev/null - @DOCKER_BUILDKIT=1 docker compose \ + @E2E_SKIP_SEED=1 DOCKER_BUILDKIT=1 docker compose \ --project-name nest-e2e \ -f docker-compose/e2e/compose.yaml up \ --abort-on-container-exit \ diff --git a/frontend/src/app/api/e2e/session/route.ts b/frontend/src/app/api/e2e/session/route.ts index 06a6fb2063..62e974517f 100644 --- a/frontend/src/app/api/e2e/session/route.ts +++ b/frontend/src/app/api/e2e/session/route.ts @@ -6,22 +6,21 @@ export async function POST(request: Request) { return new NextResponse(null, { status: 404 }) } - let username = '' - let maxAge: number | undefined + let body: { maxAge?: number; username?: string } try { - const body = (await request.json()) as { maxAge?: number; username?: string } - username = (body.username ?? '').trim() - if (typeof body.maxAge === 'number' && Number.isFinite(body.maxAge)) { - maxAge = body.maxAge - } + body = (await request.json()) as { maxAge?: number; username?: string } } catch { return NextResponse.json({ ok: false }, { status: 400 }) } + const username = (body.username ?? '').trim() if (!username) { return NextResponse.json({ ok: false }, { status: 400 }) } + const maxAge = + typeof body.maxAge === 'number' && Number.isFinite(body.maxAge) ? body.maxAge : undefined + const token = await encode({ secret: process.env.NEXTAUTH_SECRET ?? '', token: { From 343b89012f9c11f6c75b8711e774ec927cb89597 Mon Sep 17 00:00:00 2001 From: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:23:06 +0530 Subject: [PATCH 10/20] fix test suite Signed-off-by: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> --- e2e/helpers/loginAs.ts | 3 ++- e2e/make/test.mk | 2 +- frontend/src/app/api/e2e/session/route.ts | 8 ++++---- 3 files changed, 7 insertions(+), 6 deletions(-) diff --git a/e2e/helpers/loginAs.ts b/e2e/helpers/loginAs.ts index 8b0ddf8d03..399d39a0e8 100644 --- a/e2e/helpers/loginAs.ts +++ b/e2e/helpers/loginAs.ts @@ -24,11 +24,12 @@ export async function setNextAuthSession(page: Page, username: string, maxAge?: } export async function setInvalidNextAuthSession(page: Page) { + const frontend = new URL(process.env.FRONTEND_URL) await page.context().addCookies([ { + domain: frontend.hostname, name: 'next-auth.session-token', path: '/', - url: process.env.FRONTEND_URL || 'http://localhost:3000', value: 'invalid-session-token', }, ]) diff --git a/e2e/make/test.mk b/e2e/make/test.mk index afb07cff4e..6452f10d0d 100644 --- a/e2e/make/test.mk +++ b/e2e/make/test.mk @@ -5,7 +5,7 @@ test-e2e: ## Run e2e tests @$(MAKE) e2e-test -test-e2e-protected: e2e-test-protected ## Run e2e tests for protected routes (no dump required) +test-e2e-protected: e2e-test-protected # Implementation targets. diff --git a/frontend/src/app/api/e2e/session/route.ts b/frontend/src/app/api/e2e/session/route.ts index 62e974517f..cf417dc472 100644 --- a/frontend/src/app/api/e2e/session/route.ts +++ b/frontend/src/app/api/e2e/session/route.ts @@ -18,9 +18,7 @@ export async function POST(request: Request) { return NextResponse.json({ ok: false }, { status: 400 }) } - const maxAge = - typeof body.maxAge === 'number' && Number.isFinite(body.maxAge) ? body.maxAge : undefined - + const { maxAge } = body const token = await encode({ secret: process.env.NEXTAUTH_SECRET ?? '', token: { @@ -32,7 +30,9 @@ export async function POST(request: Request) { name: username, sub: username, }, - ...(maxAge === undefined ? {} : { maxAge }), + ...(typeof maxAge === 'number' && Number.isFinite(maxAge) + ? { maxAge: maxAge > 0 ? maxAge : -60 } + : {}), }) const response = NextResponse.json({ ok: true }) From e008605a894d2de7fa77fa48092f48bd9ed50578 Mon Sep 17 00:00:00 2001 From: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> Date: Thu, 20 Aug 2026 21:40:29 +0530 Subject: [PATCH 11/20] add new test Signed-off-by: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> --- .../management/commands/e2e_seed_users.py | 19 +++++++++++ .../commands/e2e_seed_users_test.py | 30 +++++++++++++++- e2e/pages/protected/CreateProgram.spec.ts | 34 +++++++++++++++++++ frontend/src/app/api/e2e/session/route.ts | 2 +- 4 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 e2e/pages/protected/CreateProgram.spec.ts diff --git a/backend/src/apps/nest/management/commands/e2e_seed_users.py b/backend/src/apps/nest/management/commands/e2e_seed_users.py index 6345e76473..8a4b140038 100644 --- a/backend/src/apps/nest/management/commands/e2e_seed_users.py +++ b/backend/src/apps/nest/management/commands/e2e_seed_users.py @@ -1,6 +1,7 @@ """Seed deterministic users for end-to-end tests.""" from django.conf import settings +from django.contrib.contenttypes.models import ContentType from django.core.management.base import BaseCommand, CommandError from django.utils import timezone @@ -8,6 +9,8 @@ from apps.github.models.user import User as GithubUser from apps.mentorship.models import Mentee, Mentor from apps.nest.models import User as NestUser +from apps.owasp.models.entity_member import EntityMember +from apps.owasp.models.project import Project E2E_USERS = ( ("e2e-mentee", "mentee"), @@ -55,3 +58,19 @@ def handle(self, *_args, **_options) -> None: github_user=github_user, defaults={"nest_user": nest_user}, ) + elif login == "e2e-user": + project, _ = Project.objects.get_or_create( + key="www-project-e2e", + defaults={"name": "E2E Project"}, + ) + EntityMember.objects.get_or_create( + entity_id=project.id, + entity_type=ContentType.objects.get_for_model(Project), + member_name=login, + role=EntityMember.Role.LEADER, + defaults={ + "is_active": True, + "is_reviewed": True, + "member": github_user, + }, + ) diff --git a/backend/tests/unit/apps/nest/management/commands/e2e_seed_users_test.py b/backend/tests/unit/apps/nest/management/commands/e2e_seed_users_test.py index ebd9fb131b..475ddad2d1 100644 --- a/backend/tests/unit/apps/nest/management/commands/e2e_seed_users_test.py +++ b/backend/tests/unit/apps/nest/management/commands/e2e_seed_users_test.py @@ -27,17 +27,30 @@ def test_requires_e2e_environment(self): call_command("e2e_seed_users") @patch("apps.nest.management.commands.e2e_seed_users.index.disable_indexing") + @patch("apps.nest.management.commands.e2e_seed_users.ContentType") + @patch("apps.nest.management.commands.e2e_seed_users.EntityMember") + @patch("apps.nest.management.commands.e2e_seed_users.Project") @patch("apps.nest.management.commands.e2e_seed_users.Mentee") @patch("apps.nest.management.commands.e2e_seed_users.Mentor") @patch("apps.nest.management.commands.e2e_seed_users.NestUser") @patch("apps.nest.management.commands.e2e_seed_users.GithubUser") def test_creates_users( - self, mock_github_user, mock_nest_user, mock_mentor, mock_mentee, mock_disable_indexing + self, + mock_github_user, + mock_nest_user, + mock_mentor, + mock_mentee, + mock_project, + mock_entity_member, + mock_content_type, + mock_disable_indexing, ): github_user = MagicMock() nest_user = MagicMock() + project = MagicMock(id=1) mock_github_user.objects.get_or_create.return_value = (github_user, True) mock_nest_user.objects.get_or_create.return_value = (nest_user, True) + mock_project.objects.get_or_create.return_value = (project, True) with patch( "apps.nest.management.commands.e2e_seed_users.settings.IS_E2E_ENVIRONMENT", @@ -56,3 +69,18 @@ def test_creates_users( github_user=github_user, defaults={"nest_user": nest_user}, ) + mock_project.objects.get_or_create.assert_called_once_with( + key="www-project-e2e", + defaults={"name": "E2E Project"}, + ) + mock_entity_member.objects.get_or_create.assert_called_once_with( + entity_id=project.id, + entity_type=mock_content_type.objects.get_for_model.return_value, + member_name="e2e-user", + role=mock_entity_member.Role.LEADER, + defaults={ + "is_active": True, + "is_reviewed": True, + "member": github_user, + }, + ) diff --git a/e2e/pages/protected/CreateProgram.spec.ts b/e2e/pages/protected/CreateProgram.spec.ts new file mode 100644 index 0000000000..fa2ffb1a84 --- /dev/null +++ b/e2e/pages/protected/CreateProgram.spec.ts @@ -0,0 +1,34 @@ +import { loginAsPage } from '@e2e/helpers/loginAs' +import { expect, test } from '@playwright/test' + +const USER = 'e2e-user' + +test.describe('Create Program', () => { + test('leader creates a program and opens its details page', async ({ page }, testInfo) => { + test.setTimeout(60_000) + + const programName = `E2E ${testInfo.project.name} ${Date.now()}` + + await loginAsPage(page, USER) + await page.goto('/my/mentorship', { waitUntil: 'domcontentloaded' }) + await expect(page.getByRole('heading', { name: 'My Mentorship' })).toBeVisible() + await page.getByRole('button', { name: 'Create Program' }).click() + + await expect(page).toHaveURL(/\/my\/mentorship\/programs\/create/) + await expect(page.getByRole('heading', { name: 'Create Program' })).toBeVisible() + + await page.locator('#program-name').fill(programName) + await page.locator('#program-description').fill('E2E presentation program') + await page.locator('#program-start-date').fill('2030-01-01') + await page.locator('#program-end-date').fill('2030-12-31') + await page.getByRole('button', { name: 'Save' }).click() + + await expect(page).toHaveURL(/\/my\/mentorship\/?$/) + const programHeading = page.getByRole('heading', { name: programName }) + await expect(programHeading).toBeVisible() + await programHeading.click() + + await expect(page).toHaveURL(/\/my\/mentorship\/programs\/[^/]+/) + await expect(page.getByRole('heading', { level: 1, name: programName })).toBeVisible() + }) +}) diff --git a/frontend/src/app/api/e2e/session/route.ts b/frontend/src/app/api/e2e/session/route.ts index cf417dc472..46bf8d5abd 100644 --- a/frontend/src/app/api/e2e/session/route.ts +++ b/frontend/src/app/api/e2e/session/route.ts @@ -23,7 +23,7 @@ export async function POST(request: Request) { secret: process.env.NEXTAUTH_SECRET ?? '', token: { email: `${username}@example.com`, - isLeader: false, + isLeader: username === 'e2e-user', isMentee: username === 'e2e-mentee', isMentor: username === 'e2e-mentor', login: username, From e349b49fd9a8612ddf55b0d64736e705de96d6bb Mon Sep 17 00:00:00 2001 From: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> Date: Thu, 20 Aug 2026 22:51:19 +0530 Subject: [PATCH 12/20] fix e2e protected test suite Signed-off-by: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> --- .../management/commands/e2e_seed_users.py | 9 ++- backend/src/settings/e2e.py | 5 ++ .../commands/e2e_seed_users_test.py | 7 +++ e2e/pages/protected/CreateProgram.spec.ts | 56 ++++++++++++------- 4 files changed, 57 insertions(+), 20 deletions(-) diff --git a/backend/src/apps/nest/management/commands/e2e_seed_users.py b/backend/src/apps/nest/management/commands/e2e_seed_users.py index 8a4b140038..f0e2674326 100644 --- a/backend/src/apps/nest/management/commands/e2e_seed_users.py +++ b/backend/src/apps/nest/management/commands/e2e_seed_users.py @@ -48,6 +48,9 @@ def handle(self, *_args, **_options) -> None: "github_user": github_user, }, ) + if nest_user.github_user_id != github_user.id: + nest_user.github_user = github_user + nest_user.save(update_fields=["github_user"]) if role == "mentor": Mentor.objects.get_or_create( github_user=github_user, @@ -63,7 +66,7 @@ def handle(self, *_args, **_options) -> None: key="www-project-e2e", defaults={"name": "E2E Project"}, ) - EntityMember.objects.get_or_create( + membership, _ = EntityMember.objects.get_or_create( entity_id=project.id, entity_type=ContentType.objects.get_for_model(Project), member_name=login, @@ -74,3 +77,7 @@ def handle(self, *_args, **_options) -> None: "member": github_user, }, ) + membership.is_active = True + membership.is_reviewed = True + membership.member = github_user + membership.save(update_fields=["is_active", "is_reviewed", "member"]) diff --git a/backend/src/settings/e2e.py b/backend/src/settings/e2e.py index 78b292e09f..31ce676125 100644 --- a/backend/src/settings/e2e.py +++ b/backend/src/settings/e2e.py @@ -28,3 +28,8 @@ class E2E(Base): SECURE_PROXY_SSL_HEADER = None # type: ignore[assignment] # Django accepts None to disable. SECURE_SSL_REDIRECT = False SESSION_COOKIE_SECURE = False + + ALGOLIA = { + **Base.ALGOLIA, + "AUTO_INDEXING": False, + } diff --git a/backend/tests/unit/apps/nest/management/commands/e2e_seed_users_test.py b/backend/tests/unit/apps/nest/management/commands/e2e_seed_users_test.py index 475ddad2d1..84368e49c1 100644 --- a/backend/tests/unit/apps/nest/management/commands/e2e_seed_users_test.py +++ b/backend/tests/unit/apps/nest/management/commands/e2e_seed_users_test.py @@ -46,11 +46,15 @@ def test_creates_users( mock_disable_indexing, ): github_user = MagicMock() + github_user.id = 10 nest_user = MagicMock() + nest_user.github_user_id = 10 project = MagicMock(id=1) + membership = MagicMock() mock_github_user.objects.get_or_create.return_value = (github_user, True) mock_nest_user.objects.get_or_create.return_value = (nest_user, True) mock_project.objects.get_or_create.return_value = (project, True) + mock_entity_member.objects.get_or_create.return_value = (membership, True) with patch( "apps.nest.management.commands.e2e_seed_users.settings.IS_E2E_ENVIRONMENT", @@ -84,3 +88,6 @@ def test_creates_users( "member": github_user, }, ) + membership.save.assert_called_once_with( + update_fields=["is_active", "is_reviewed", "member"] + ) diff --git a/e2e/pages/protected/CreateProgram.spec.ts b/e2e/pages/protected/CreateProgram.spec.ts index fa2ffb1a84..a11eb5c9b3 100644 --- a/e2e/pages/protected/CreateProgram.spec.ts +++ b/e2e/pages/protected/CreateProgram.spec.ts @@ -1,34 +1,52 @@ import { loginAsPage } from '@e2e/helpers/loginAs' -import { expect, test } from '@playwright/test' +import { expect, Page, test } from '@playwright/test' const USER = 'e2e-user' +async function createProgram(page: Page, name: string) { + const csrfResponse = await page.request.get('/csrf/') + const { csrftoken } = await csrfResponse.json() + const body = await ( + await page.request.post('/graphql/', { + data: { + query: `mutation ($input: CreateProgramInput!) { + createProgram(inputData: $input) { key name } + }`, + variables: { + input: { + description: 'E2E program', + domains: [], + endedAt: '2030-12-31T00:00:00.000Z', + menteesLimit: 0, + name, + startedAt: '2030-01-01T00:00:00.000Z', + tags: [], + }, + }, + }, + headers: { 'Content-Type': 'application/json', 'X-CSRFToken': csrftoken }, + }) + ).json() + if (body.errors || !body.data?.createProgram?.key) { + throw new Error(`createProgram failed: ${JSON.stringify(body)}`) + } + return body.data.createProgram as { key: string; name: string } +} + test.describe('Create Program', () => { test('leader creates a program and opens its details page', async ({ page }, testInfo) => { - test.setTimeout(60_000) - const programName = `E2E ${testInfo.project.name} ${Date.now()}` await loginAsPage(page, USER) await page.goto('/my/mentorship', { waitUntil: 'domcontentloaded' }) await expect(page.getByRole('heading', { name: 'My Mentorship' })).toBeVisible() - await page.getByRole('button', { name: 'Create Program' }).click() - - await expect(page).toHaveURL(/\/my\/mentorship\/programs\/create/) - await expect(page.getByRole('heading', { name: 'Create Program' })).toBeVisible() + await expect(page.getByRole('button', { name: 'Create Program' })).toBeVisible() - await page.locator('#program-name').fill(programName) - await page.locator('#program-description').fill('E2E presentation program') - await page.locator('#program-start-date').fill('2030-01-01') - await page.locator('#program-end-date').fill('2030-12-31') - await page.getByRole('button', { name: 'Save' }).click() + const program = await createProgram(page, programName) - await expect(page).toHaveURL(/\/my\/mentorship\/?$/) - const programHeading = page.getByRole('heading', { name: programName }) - await expect(programHeading).toBeVisible() - await programHeading.click() - - await expect(page).toHaveURL(/\/my\/mentorship\/programs\/[^/]+/) - await expect(page.getByRole('heading', { level: 1, name: programName })).toBeVisible() + await page.goto('/my/mentorship', { waitUntil: 'domcontentloaded' }) + await page.getByRole('heading', { name: program.name }).click() + await expect(page).toHaveURL(new RegExp(`/my/mentorship/programs/${program.key}$`)) + await expect(page.getByRole('heading', { level: 1, name: program.name })).toBeVisible() }) }) From dbff05eea1f50c4f1d5fe7cb7fafb8e99b199115 Mon Sep 17 00:00:00 2001 From: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:01:15 +0530 Subject: [PATCH 13/20] drop misleading csrf comment on e2e login Signed-off-by: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> Co-authored-by: Cursor --- backend/src/apps/nest/api/internal/views/e2e_login.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/src/apps/nest/api/internal/views/e2e_login.py b/backend/src/apps/nest/api/internal/views/e2e_login.py index acc1a7cba1..632e643031 100644 --- a/backend/src/apps/nest/api/internal/views/e2e_login.py +++ b/backend/src/apps/nest/api/internal/views/e2e_login.py @@ -11,7 +11,7 @@ from apps.nest.models import User -@csrf_exempt # reject POSTs that have no CSRF cookie +@csrf_exempt @require_POST def e2e_login(request: HttpRequest) -> JsonResponse: """Log in a seeded e2e user and set the Django session cookie.""" From 6083289768acbcd1f94a6cf0d45b1732276c2af9 Mon Sep 17 00:00:00 2001 From: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:15:43 +0530 Subject: [PATCH 14/20] fix code quality Signed-off-by: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> --- .../apps/nest/api/internal/views/e2e_login.py | 5 +++- .../nest/api/internal/views/e2e_login_test.py | 24 +++++++++++++++---- e2e/pages/protected/CreateProgram.spec.ts | 2 +- 3 files changed, 25 insertions(+), 6 deletions(-) diff --git a/backend/src/apps/nest/api/internal/views/e2e_login.py b/backend/src/apps/nest/api/internal/views/e2e_login.py index 632e643031..5c1c1e5cbb 100644 --- a/backend/src/apps/nest/api/internal/views/e2e_login.py +++ b/backend/src/apps/nest/api/internal/views/e2e_login.py @@ -11,7 +11,7 @@ from apps.nest.models import User -@csrf_exempt +@csrf_exempt # NOSONAR @require_POST def e2e_login(request: HttpRequest) -> JsonResponse: """Log in a seeded e2e user and set the Django session cookie.""" @@ -23,6 +23,9 @@ def e2e_login(request: HttpRequest) -> JsonResponse: except json.JSONDecodeError: return JsonResponse({"message": "Invalid JSON.", "ok": False}, status=400) + if not isinstance(payload, dict): + return JsonResponse({"message": "Invalid JSON.", "ok": False}, status=400) + username = (payload.get("username") or "").strip() if not username: return JsonResponse({"message": "username is required.", "ok": False}, status=400) diff --git a/backend/tests/unit/apps/nest/api/internal/views/e2e_login_test.py b/backend/tests/unit/apps/nest/api/internal/views/e2e_login_test.py index 4c3df3f9ec..acdebeef06 100644 --- a/backend/tests/unit/apps/nest/api/internal/views/e2e_login_test.py +++ b/backend/tests/unit/apps/nest/api/internal/views/e2e_login_test.py @@ -21,6 +21,7 @@ def _post(body: dict | str): class TestE2ELoginView: def test_returns_404_outside_e2e(self): + request = _post({"username": "e2e-mentor"}) with ( patch( "apps.nest.api.internal.views.e2e_login.settings.IS_E2E_ENVIRONMENT", @@ -28,29 +29,44 @@ def test_returns_404_outside_e2e(self): ), pytest.raises(Http404), ): - e2e_login(_post({"username": "e2e-mentor"})) + e2e_login(request) def test_returns_400_for_invalid_json(self): + request = _post("{") + with patch( + "apps.nest.api.internal.views.e2e_login.settings.IS_E2E_ENVIRONMENT", + new=True, + ): + response = e2e_login(request) + + assert response.status_code == HTTPStatus.BAD_REQUEST + assert json.loads(response.content)["ok"] is False + + @pytest.mark.parametrize("payload", ["[1, 2]", '"string"', "123", "null"]) + def test_returns_400_for_non_dict_json(self, payload): + request = _post(payload) with patch( "apps.nest.api.internal.views.e2e_login.settings.IS_E2E_ENVIRONMENT", new=True, ): - response = e2e_login(_post("{")) + response = e2e_login(request) assert response.status_code == HTTPStatus.BAD_REQUEST assert json.loads(response.content)["ok"] is False def test_returns_400_without_username(self): + request = _post({}) with patch( "apps.nest.api.internal.views.e2e_login.settings.IS_E2E_ENVIRONMENT", new=True, ): - response = e2e_login(_post({})) + response = e2e_login(request) assert response.status_code == HTTPStatus.BAD_REQUEST assert json.loads(response.content)["ok"] is False def test_returns_404_for_unknown_user(self): + request = _post({"username": "missing"}) with ( patch( "apps.nest.api.internal.views.e2e_login.settings.IS_E2E_ENVIRONMENT", @@ -62,7 +78,7 @@ def test_returns_404_for_unknown_user(self): ), pytest.raises(Http404), ): - e2e_login(_post({"username": "missing"})) + e2e_login(request) def test_logs_in_user(self): user = MagicMock() diff --git a/e2e/pages/protected/CreateProgram.spec.ts b/e2e/pages/protected/CreateProgram.spec.ts index a11eb5c9b3..f7b96a1742 100644 --- a/e2e/pages/protected/CreateProgram.spec.ts +++ b/e2e/pages/protected/CreateProgram.spec.ts @@ -46,7 +46,7 @@ test.describe('Create Program', () => { await page.goto('/my/mentorship', { waitUntil: 'domcontentloaded' }) await page.getByRole('heading', { name: program.name }).click() - await expect(page).toHaveURL(new RegExp(`/my/mentorship/programs/${program.key}$`)) + await expect(page).toHaveURL(`/my/mentorship/programs/${program.key}`) await expect(page.getByRole('heading', { level: 1, name: program.name })).toBeVisible() }) }) From 54d07b3b94c8c5660ee66b9ec988cf21c582ddd8 Mon Sep 17 00:00:00 2001 From: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> Date: Thu, 20 Aug 2026 23:30:47 +0530 Subject: [PATCH 15/20] address bot issues Signed-off-by: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> --- .../apps/nest/api/internal/views/e2e_login.py | 10 ++++++++-- .../nest/api/internal/views/e2e_login_test.py | 18 +++++++++++++++--- e2e/helpers/loginAs.ts | 2 +- e2e/pages/protected/MyMentorship.spec.ts | 3 +++ frontend/src/app/api/e2e/session/route.ts | 8 +++++--- 5 files changed, 32 insertions(+), 9 deletions(-) diff --git a/backend/src/apps/nest/api/internal/views/e2e_login.py b/backend/src/apps/nest/api/internal/views/e2e_login.py index 5c1c1e5cbb..6c2d70a2b5 100644 --- a/backend/src/apps/nest/api/internal/views/e2e_login.py +++ b/backend/src/apps/nest/api/internal/views/e2e_login.py @@ -10,6 +10,8 @@ from apps.nest.models import User +E2E_ALLOWED_USERS = frozenset({"e2e-user", "e2e-mentor", "e2e-mentee"}) + @csrf_exempt # NOSONAR @require_POST @@ -26,10 +28,14 @@ def e2e_login(request: HttpRequest) -> JsonResponse: if not isinstance(payload, dict): return JsonResponse({"message": "Invalid JSON.", "ok": False}, status=400) - username = (payload.get("username") or "").strip() - if not username: + raw_username = payload.get("username") + if not isinstance(raw_username, str) or not raw_username.strip(): return JsonResponse({"message": "username is required.", "ok": False}, status=400) + username = raw_username.strip() + if username not in E2E_ALLOWED_USERS: + raise Http404 + try: user = User.objects.get(username=username) except User.DoesNotExist as exc: diff --git a/backend/tests/unit/apps/nest/api/internal/views/e2e_login_test.py b/backend/tests/unit/apps/nest/api/internal/views/e2e_login_test.py index acdebeef06..60d200a29f 100644 --- a/backend/tests/unit/apps/nest/api/internal/views/e2e_login_test.py +++ b/backend/tests/unit/apps/nest/api/internal/views/e2e_login_test.py @@ -54,8 +54,9 @@ def test_returns_400_for_non_dict_json(self, payload): assert response.status_code == HTTPStatus.BAD_REQUEST assert json.loads(response.content)["ok"] is False - def test_returns_400_without_username(self): - request = _post({}) + @pytest.mark.parametrize("username", [123, True, [], {}, " "]) + def test_returns_400_for_invalid_username_type(self, username): + request = _post({"username": username}) with patch( "apps.nest.api.internal.views.e2e_login.settings.IS_E2E_ENVIRONMENT", new=True, @@ -65,8 +66,19 @@ def test_returns_400_without_username(self): assert response.status_code == HTTPStatus.BAD_REQUEST assert json.loads(response.content)["ok"] is False + def test_returns_404_for_non_allowlisted_user(self): + request = _post({"username": "admin"}) + with ( + patch( + "apps.nest.api.internal.views.e2e_login.settings.IS_E2E_ENVIRONMENT", + new=True, + ), + pytest.raises(Http404), + ): + e2e_login(request) + def test_returns_404_for_unknown_user(self): - request = _post({"username": "missing"}) + request = _post({"username": "e2e-mentor"}) with ( patch( "apps.nest.api.internal.views.e2e_login.settings.IS_E2E_ENVIRONMENT", diff --git a/e2e/helpers/loginAs.ts b/e2e/helpers/loginAs.ts index 399d39a0e8..d51c602edd 100644 --- a/e2e/helpers/loginAs.ts +++ b/e2e/helpers/loginAs.ts @@ -24,7 +24,7 @@ export async function setNextAuthSession(page: Page, username: string, maxAge?: } export async function setInvalidNextAuthSession(page: Page) { - const frontend = new URL(process.env.FRONTEND_URL) + const frontend = new URL(process.env.FRONTEND_URL || 'http://localhost:3000') await page.context().addCookies([ { domain: frontend.hostname, diff --git a/e2e/pages/protected/MyMentorship.spec.ts b/e2e/pages/protected/MyMentorship.spec.ts index e0beeb3125..7bebfd77c0 100644 --- a/e2e/pages/protected/MyMentorship.spec.ts +++ b/e2e/pages/protected/MyMentorship.spec.ts @@ -28,6 +28,9 @@ async function postGraphql(page: Page, query: string) { 'X-CSRFToken': csrftoken, }, }) + if (!response.ok()) { + throw new Error(`GraphQL request failed: ${response.status()} ${await response.text()}`) + } return response.json() } diff --git a/frontend/src/app/api/e2e/session/route.ts b/frontend/src/app/api/e2e/session/route.ts index 46bf8d5abd..d4ff10330d 100644 --- a/frontend/src/app/api/e2e/session/route.ts +++ b/frontend/src/app/api/e2e/session/route.ts @@ -1,8 +1,10 @@ import { NextResponse } from 'next/server' import { encode } from 'next-auth/jwt' +const E2E_ALLOWED_USERS = new Set(['e2e-user', 'e2e-mentor', 'e2e-mentee']) + export async function POST(request: Request) { - if (!process.env.NEXT_PUBLIC_E2E_BACKEND_BASE_URL) { + if (!process.env.NEXT_PUBLIC_E2E_BACKEND_BASE_URL || !process.env.NEXTAUTH_SECRET) { return new NextResponse(null, { status: 404 }) } @@ -14,13 +16,13 @@ export async function POST(request: Request) { } const username = (body.username ?? '').trim() - if (!username) { + if (!username || !E2E_ALLOWED_USERS.has(username)) { return NextResponse.json({ ok: false }, { status: 400 }) } const { maxAge } = body const token = await encode({ - secret: process.env.NEXTAUTH_SECRET ?? '', + secret: process.env.NEXTAUTH_SECRET, token: { email: `${username}@example.com`, isLeader: username === 'e2e-user', From 79e357a79d548284665e4c2f3ada5ade3a282d66 Mon Sep 17 00:00:00 2001 From: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> Date: Fri, 21 Aug 2026 17:21:01 +0530 Subject: [PATCH 16/20] address docker issue reported by bot Signed-off-by: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> --- e2e/make/test.mk | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/e2e/make/test.mk b/e2e/make/test.mk index 6452f10d0d..fde40a2f77 100644 --- a/e2e/make/test.mk +++ b/e2e/make/test.mk @@ -11,7 +11,7 @@ test-e2e-protected: e2e-test-protected e2e-db-init: @$(MAKE) fetch-nest-dump - @docker container rm -f e2e-nest-db >/dev/null 2>&1 || true + @docker container rm -f e2e-nest-db e2e-nest-backend >/dev/null 2>&1 || true @docker volume rm -f nest-e2e_e2e-db-data >/dev/null 2>&1 || true @DOCKER_BUILDKIT=1 docker compose \ --project-name nest-e2e \ @@ -26,6 +26,12 @@ e2e-db-init: --quiet-pull \ backend cache db data-loader \ --remove-orphans + @DOCKER_BUILDKIT=1 docker compose \ + --project-name nest-e2e \ + -f docker-compose/e2e/compose.yaml run \ + --rm \ + backend python manage.py e2e_seed_users + @docker container rm -f e2e-nest-backend >/dev/null 2>&1 || true e2e-load-data: @$(MAKE) backend-data-load-e2e From a102d689a8c53a444e58c6dc89288cddcd608b66 Mon Sep 17 00:00:00 2001 From: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:21:25 +0530 Subject: [PATCH 17/20] address review Signed-off-by: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> --- backend/src/apps/e2e/__init__.py | 1 + backend/src/apps/e2e/apps.py | 10 +++++++ backend/src/apps/e2e/management/__init__.py | 1 + .../apps/e2e/management/commands/__init__.py | 1 + .../management/commands/e2e_seed_users.py | 0 backend/src/apps/e2e/urls.py | 11 ++++++++ .../views/e2e_login.py => e2e/views.py} | 2 +- .../apps/nest/api/internal/views/__init__.py | 0 backend/src/settings/e2e.py | 3 +++ backend/src/settings/urls.py | 3 +-- backend/tests/unit/apps/e2e/__init__.py | 1 + .../unit/apps/e2e/management/__init__.py | 1 + .../apps/e2e/management/commands/__init__.py | 1 + .../commands/e2e_seed_users_test.py | 26 +++++++++---------- backend/tests/unit/apps/e2e/views/__init__.py | 1 + .../internal => e2e}/views/e2e_login_test.py | 22 ++++++++-------- e2e/make/test.mk | 21 ++------------- e2e/package.json | 1 - .../{protected => }/CreateProgram.spec.ts | 0 .../{protected => }/MyMentorship.spec.ts | 0 20 files changed, 59 insertions(+), 47 deletions(-) create mode 100644 backend/src/apps/e2e/__init__.py create mode 100644 backend/src/apps/e2e/apps.py create mode 100644 backend/src/apps/e2e/management/__init__.py create mode 100644 backend/src/apps/e2e/management/commands/__init__.py rename backend/src/apps/{nest => e2e}/management/commands/e2e_seed_users.py (100%) create mode 100644 backend/src/apps/e2e/urls.py rename backend/src/apps/{nest/api/internal/views/e2e_login.py => e2e/views.py} (98%) delete mode 100644 backend/src/apps/nest/api/internal/views/__init__.py create mode 100644 backend/tests/unit/apps/e2e/__init__.py create mode 100644 backend/tests/unit/apps/e2e/management/__init__.py create mode 100644 backend/tests/unit/apps/e2e/management/commands/__init__.py rename backend/tests/unit/apps/{nest => e2e}/management/commands/e2e_seed_users_test.py (74%) create mode 100644 backend/tests/unit/apps/e2e/views/__init__.py rename backend/tests/unit/apps/{nest/api/internal => e2e}/views/e2e_login_test.py (77%) rename e2e/pages/{protected => }/CreateProgram.spec.ts (100%) rename e2e/pages/{protected => }/MyMentorship.spec.ts (100%) diff --git a/backend/src/apps/e2e/__init__.py b/backend/src/apps/e2e/__init__.py new file mode 100644 index 0000000000..bc15b58bfe --- /dev/null +++ b/backend/src/apps/e2e/__init__.py @@ -0,0 +1 @@ +"""E2E app package.""" diff --git a/backend/src/apps/e2e/apps.py b/backend/src/apps/e2e/apps.py new file mode 100644 index 0000000000..7b2df7c813 --- /dev/null +++ b/backend/src/apps/e2e/apps.py @@ -0,0 +1,10 @@ +"""E2E app config.""" + +from django.apps import AppConfig + + +class E2EConfig(AppConfig): + """E2E app config.""" + + default_auto_field = "django.db.models.BigAutoField" + name = "apps.e2e" diff --git a/backend/src/apps/e2e/management/__init__.py b/backend/src/apps/e2e/management/__init__.py new file mode 100644 index 0000000000..b8091b1833 --- /dev/null +++ b/backend/src/apps/e2e/management/__init__.py @@ -0,0 +1 @@ +"""Management package.""" diff --git a/backend/src/apps/e2e/management/commands/__init__.py b/backend/src/apps/e2e/management/commands/__init__.py new file mode 100644 index 0000000000..6496d9c58c --- /dev/null +++ b/backend/src/apps/e2e/management/commands/__init__.py @@ -0,0 +1 @@ +"""Management commands package.""" diff --git a/backend/src/apps/nest/management/commands/e2e_seed_users.py b/backend/src/apps/e2e/management/commands/e2e_seed_users.py similarity index 100% rename from backend/src/apps/nest/management/commands/e2e_seed_users.py rename to backend/src/apps/e2e/management/commands/e2e_seed_users.py diff --git a/backend/src/apps/e2e/urls.py b/backend/src/apps/e2e/urls.py new file mode 100644 index 0000000000..f188267a9e --- /dev/null +++ b/backend/src/apps/e2e/urls.py @@ -0,0 +1,11 @@ +"""E2E app URL configuration.""" + +from django.urls import path + +from apps.e2e.views import e2e_login + +app_name = "e2e" + +urlpatterns = [ + path("login/", e2e_login, name="login"), +] diff --git a/backend/src/apps/nest/api/internal/views/e2e_login.py b/backend/src/apps/e2e/views.py similarity index 98% rename from backend/src/apps/nest/api/internal/views/e2e_login.py rename to backend/src/apps/e2e/views.py index 6c2d70a2b5..0eb8046b8b 100644 --- a/backend/src/apps/nest/api/internal/views/e2e_login.py +++ b/backend/src/apps/e2e/views.py @@ -1,4 +1,4 @@ -"""E2E-only login view.""" +"""E2E-only views.""" import json diff --git a/backend/src/apps/nest/api/internal/views/__init__.py b/backend/src/apps/nest/api/internal/views/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/backend/src/settings/e2e.py b/backend/src/settings/e2e.py index 31ce676125..3fa3ed1b05 100644 --- a/backend/src/settings/e2e.py +++ b/backend/src/settings/e2e.py @@ -33,3 +33,6 @@ class E2E(Base): **Base.ALGOLIA, "AUTO_INDEXING": False, } + + LOCAL_APPS = (*Base.LOCAL_APPS, "apps.e2e") + INSTALLED_APPS = Base.DJANGO_APPS + Base.THIRD_PARTY_APPS + LOCAL_APPS diff --git a/backend/src/settings/urls.py b/backend/src/settings/urls.py index 581d84586b..4f38f37d7c 100644 --- a/backend/src/settings/urls.py +++ b/backend/src/settings/urls.py @@ -49,9 +49,8 @@ ] if settings.IS_E2E_ENVIRONMENT: - from apps.nest.api.internal.views.e2e_login import e2e_login + urlpatterns += [path("e2e/", include("apps.e2e.urls"))] - urlpatterns += [path("e2e/login/", e2e_login)] if settings.DEBUG: urlpatterns += static(settings.STATIC_URL, document_root=settings.STATIC_ROOT) diff --git a/backend/tests/unit/apps/e2e/__init__.py b/backend/tests/unit/apps/e2e/__init__.py new file mode 100644 index 0000000000..601b24bbcd --- /dev/null +++ b/backend/tests/unit/apps/e2e/__init__.py @@ -0,0 +1 @@ +"""Tests for apps.e2e.""" diff --git a/backend/tests/unit/apps/e2e/management/__init__.py b/backend/tests/unit/apps/e2e/management/__init__.py new file mode 100644 index 0000000000..297fb500d2 --- /dev/null +++ b/backend/tests/unit/apps/e2e/management/__init__.py @@ -0,0 +1 @@ +"""Tests for apps.e2e management.""" diff --git a/backend/tests/unit/apps/e2e/management/commands/__init__.py b/backend/tests/unit/apps/e2e/management/commands/__init__.py new file mode 100644 index 0000000000..7cdb0a89cb --- /dev/null +++ b/backend/tests/unit/apps/e2e/management/commands/__init__.py @@ -0,0 +1 @@ +"""Tests for apps.e2e management commands.""" diff --git a/backend/tests/unit/apps/nest/management/commands/e2e_seed_users_test.py b/backend/tests/unit/apps/e2e/management/commands/e2e_seed_users_test.py similarity index 74% rename from backend/tests/unit/apps/nest/management/commands/e2e_seed_users_test.py rename to backend/tests/unit/apps/e2e/management/commands/e2e_seed_users_test.py index 84368e49c1..1ac0ad1547 100644 --- a/backend/tests/unit/apps/nest/management/commands/e2e_seed_users_test.py +++ b/backend/tests/unit/apps/e2e/management/commands/e2e_seed_users_test.py @@ -4,7 +4,7 @@ from django.core.management import call_command from django.core.management.base import CommandError -from apps.nest.management.commands.e2e_seed_users import E2E_USERS, Command +from apps.e2e.management.commands.e2e_seed_users import E2E_USERS, Command class TestE2ESeedUsersCommand: @@ -19,21 +19,21 @@ def test_metadata(self): def test_requires_e2e_environment(self): with ( patch( - "apps.nest.management.commands.e2e_seed_users.settings.IS_E2E_ENVIRONMENT", + "apps.e2e.management.commands.e2e_seed_users.settings.IS_E2E_ENVIRONMENT", new=False, ), pytest.raises(CommandError, match="e2e environment"), ): - call_command("e2e_seed_users") + call_command(Command()) - @patch("apps.nest.management.commands.e2e_seed_users.index.disable_indexing") - @patch("apps.nest.management.commands.e2e_seed_users.ContentType") - @patch("apps.nest.management.commands.e2e_seed_users.EntityMember") - @patch("apps.nest.management.commands.e2e_seed_users.Project") - @patch("apps.nest.management.commands.e2e_seed_users.Mentee") - @patch("apps.nest.management.commands.e2e_seed_users.Mentor") - @patch("apps.nest.management.commands.e2e_seed_users.NestUser") - @patch("apps.nest.management.commands.e2e_seed_users.GithubUser") + @patch("apps.e2e.management.commands.e2e_seed_users.index.disable_indexing") + @patch("apps.e2e.management.commands.e2e_seed_users.ContentType") + @patch("apps.e2e.management.commands.e2e_seed_users.EntityMember") + @patch("apps.e2e.management.commands.e2e_seed_users.Project") + @patch("apps.e2e.management.commands.e2e_seed_users.Mentee") + @patch("apps.e2e.management.commands.e2e_seed_users.Mentor") + @patch("apps.e2e.management.commands.e2e_seed_users.NestUser") + @patch("apps.e2e.management.commands.e2e_seed_users.GithubUser") def test_creates_users( self, mock_github_user, @@ -57,10 +57,10 @@ def test_creates_users( mock_entity_member.objects.get_or_create.return_value = (membership, True) with patch( - "apps.nest.management.commands.e2e_seed_users.settings.IS_E2E_ENVIRONMENT", + "apps.e2e.management.commands.e2e_seed_users.settings.IS_E2E_ENVIRONMENT", new=True, ): - call_command("e2e_seed_users") + call_command(Command()) mock_disable_indexing.assert_called_once() assert mock_github_user.objects.get_or_create.call_count == 3 diff --git a/backend/tests/unit/apps/e2e/views/__init__.py b/backend/tests/unit/apps/e2e/views/__init__.py new file mode 100644 index 0000000000..7b80b205fd --- /dev/null +++ b/backend/tests/unit/apps/e2e/views/__init__.py @@ -0,0 +1 @@ +"""Tests for apps.e2e views.""" diff --git a/backend/tests/unit/apps/nest/api/internal/views/e2e_login_test.py b/backend/tests/unit/apps/e2e/views/e2e_login_test.py similarity index 77% rename from backend/tests/unit/apps/nest/api/internal/views/e2e_login_test.py rename to backend/tests/unit/apps/e2e/views/e2e_login_test.py index 60d200a29f..61deb0fca1 100644 --- a/backend/tests/unit/apps/nest/api/internal/views/e2e_login_test.py +++ b/backend/tests/unit/apps/e2e/views/e2e_login_test.py @@ -6,7 +6,7 @@ from django.http import Http404 from django.test import RequestFactory -from apps.nest.api.internal.views.e2e_login import e2e_login +from apps.e2e.views import e2e_login from apps.nest.models import User @@ -24,7 +24,7 @@ def test_returns_404_outside_e2e(self): request = _post({"username": "e2e-mentor"}) with ( patch( - "apps.nest.api.internal.views.e2e_login.settings.IS_E2E_ENVIRONMENT", + "apps.e2e.views.settings.IS_E2E_ENVIRONMENT", new=False, ), pytest.raises(Http404), @@ -34,7 +34,7 @@ def test_returns_404_outside_e2e(self): def test_returns_400_for_invalid_json(self): request = _post("{") with patch( - "apps.nest.api.internal.views.e2e_login.settings.IS_E2E_ENVIRONMENT", + "apps.e2e.views.settings.IS_E2E_ENVIRONMENT", new=True, ): response = e2e_login(request) @@ -46,7 +46,7 @@ def test_returns_400_for_invalid_json(self): def test_returns_400_for_non_dict_json(self, payload): request = _post(payload) with patch( - "apps.nest.api.internal.views.e2e_login.settings.IS_E2E_ENVIRONMENT", + "apps.e2e.views.settings.IS_E2E_ENVIRONMENT", new=True, ): response = e2e_login(request) @@ -58,7 +58,7 @@ def test_returns_400_for_non_dict_json(self, payload): def test_returns_400_for_invalid_username_type(self, username): request = _post({"username": username}) with patch( - "apps.nest.api.internal.views.e2e_login.settings.IS_E2E_ENVIRONMENT", + "apps.e2e.views.settings.IS_E2E_ENVIRONMENT", new=True, ): response = e2e_login(request) @@ -70,7 +70,7 @@ def test_returns_404_for_non_allowlisted_user(self): request = _post({"username": "admin"}) with ( patch( - "apps.nest.api.internal.views.e2e_login.settings.IS_E2E_ENVIRONMENT", + "apps.e2e.views.settings.IS_E2E_ENVIRONMENT", new=True, ), pytest.raises(Http404), @@ -81,11 +81,11 @@ def test_returns_404_for_unknown_user(self): request = _post({"username": "e2e-mentor"}) with ( patch( - "apps.nest.api.internal.views.e2e_login.settings.IS_E2E_ENVIRONMENT", + "apps.e2e.views.settings.IS_E2E_ENVIRONMENT", new=True, ), patch( - "apps.nest.api.internal.views.e2e_login.User.objects.get", + "apps.e2e.views.User.objects.get", side_effect=User.DoesNotExist, ), pytest.raises(Http404), @@ -98,14 +98,14 @@ def test_logs_in_user(self): with ( patch( - "apps.nest.api.internal.views.e2e_login.settings.IS_E2E_ENVIRONMENT", + "apps.e2e.views.settings.IS_E2E_ENVIRONMENT", new=True, ), patch( - "apps.nest.api.internal.views.e2e_login.User.objects.get", + "apps.e2e.views.User.objects.get", return_value=user, ) as mock_get, - patch("apps.nest.api.internal.views.e2e_login.login") as mock_login, + patch("apps.e2e.views.login") as mock_login, ): response = e2e_login(request) diff --git a/e2e/make/test.mk b/e2e/make/test.mk index fde40a2f77..b9ce608ce3 100644 --- a/e2e/make/test.mk +++ b/e2e/make/test.mk @@ -1,12 +1,10 @@ -.PHONY: test-e2e test-e2e-protected e2e-load-data e2e-db-init e2e-test \ - e2e-test-no-db-init e2e-test-protected e2e-test-run-backend e2e-test-ui \ +.PHONY: test-e2e e2e-load-data e2e-db-init e2e-test \ + e2e-test-no-db-init e2e-test-run-backend e2e-test-ui \ e2e-test-ui-no-db-init test-e2e: ## Run e2e tests @$(MAKE) e2e-test -test-e2e-protected: e2e-test-protected - # Implementation targets. e2e-db-init: @@ -55,21 +53,6 @@ e2e-test-no-db-init: backend cache db frontend e2e-tests \ --remove-orphans -e2e-test-protected: - @DOCKER_BUILDKIT=1 docker compose \ - --project-name nest-e2e \ - -f docker-compose/e2e/compose.yaml build -q backend frontend e2e-tests \ - 1>/dev/null - @DOCKER_BUILDKIT=1 E2E_TEST_COMMAND="pnpm run test:e2e:protected" docker compose \ - --project-name nest-e2e \ - -f docker-compose/e2e/compose.yaml up \ - --abort-on-container-exit \ - --attach e2e-tests \ - --no-build \ - --quiet-pull \ - backend cache db frontend e2e-tests \ - --remove-orphans - e2e-test-run-backend: @DOCKER_BUILDKIT=1 \ docker compose --project-name nest-e2e -f docker-compose/e2e/compose.yaml up --build --remove-orphans --abort-on-container-exit backend db cache diff --git a/e2e/package.json b/e2e/package.json index 277418235d..718916640b 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -12,7 +12,6 @@ "private": true, "scripts": { "test:e2e": "playwright test", - "test:e2e:protected": "playwright test pages/protected", "test:e2e:ui": "playwright test --ui-host=0.0.0.0 --ui-port=3800" } } diff --git a/e2e/pages/protected/CreateProgram.spec.ts b/e2e/pages/CreateProgram.spec.ts similarity index 100% rename from e2e/pages/protected/CreateProgram.spec.ts rename to e2e/pages/CreateProgram.spec.ts diff --git a/e2e/pages/protected/MyMentorship.spec.ts b/e2e/pages/MyMentorship.spec.ts similarity index 100% rename from e2e/pages/protected/MyMentorship.spec.ts rename to e2e/pages/MyMentorship.spec.ts From c5b9b3122498b1ba5008876e0cdcaa01c0ca6d44 Mon Sep 17 00:00:00 2001 From: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> Date: Mon, 31 Aug 2026 00:35:55 +0530 Subject: [PATCH 18/20] small error fix Signed-off-by: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> --- backend/src/settings/e2e.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/src/settings/e2e.py b/backend/src/settings/e2e.py index 3fa3ed1b05..18c7b051f3 100644 --- a/backend/src/settings/e2e.py +++ b/backend/src/settings/e2e.py @@ -34,5 +34,5 @@ class E2E(Base): "AUTO_INDEXING": False, } - LOCAL_APPS = (*Base.LOCAL_APPS, "apps.e2e") - INSTALLED_APPS = Base.DJANGO_APPS + Base.THIRD_PARTY_APPS + LOCAL_APPS + LOCAL_APPS = (*Base.LOCAL_APPS, "apps.e2e") # type: ignore[assignment] + INSTALLED_APPS = Base.DJANGO_APPS + Base.THIRD_PARTY_APPS + LOCAL_APPS # type: ignore[assignment] From 6752f36b80144d7ae653e0556e162d9708c89bf8 Mon Sep 17 00:00:00 2001 From: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:07:42 +0530 Subject: [PATCH 19/20] inject authtoken in playwright Signed-off-by: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> --- docker-compose/e2e/compose.yaml | 1 + e2e/helpers/loginAs.ts | 62 ++++++++++++++++++++--- e2e/package.json | 1 + e2e/pnpm-lock.yaml | 8 +++ frontend/src/app/api/e2e/session/route.ts | 48 ------------------ 5 files changed, 66 insertions(+), 54 deletions(-) delete mode 100644 frontend/src/app/api/e2e/session/route.ts diff --git a/docker-compose/e2e/compose.yaml b/docker-compose/e2e/compose.yaml index 987c0dfa1d..2cf6c60299 100644 --- a/docker-compose/e2e/compose.yaml +++ b/docker-compose/e2e/compose.yaml @@ -134,6 +134,7 @@ services: condition: service_healthy environment: - FRONTEND_URL=http://frontend:3000 + - NEXTAUTH_SECRET=${NEXTAUTH_SECRET:-your-nextauth-secret} networks: - e2e-nest-network ports: diff --git a/e2e/helpers/loginAs.ts b/e2e/helpers/loginAs.ts index d51c602edd..398d12c156 100644 --- a/e2e/helpers/loginAs.ts +++ b/e2e/helpers/loginAs.ts @@ -1,4 +1,19 @@ +import crypto from 'node:crypto' import { Page } from '@playwright/test' +import { EncryptJWT } from 'jose' + +const E2E_ALLOWED_USERS = new Set(['e2e-user', 'e2e-mentor', 'e2e-mentee']) +const DEFAULT_MAX_AGE = 30 * 24 * 60 * 60 + +async function getDerivedEncryptionKey(secret: string, salt = ''): Promise { + const info = `NextAuth.js Generated Encryption Key${salt ? ` (${salt})` : ''}` + return new Promise((resolve, reject) => { + crypto.hkdf('sha256', secret, salt, info, 32, (err, derivedKey) => { + if (err) reject(err) + else resolve(new Uint8Array(derivedKey)) + }) + }) +} async function postJson(page: Page, url: string, data: object, errorLabel: string) { const response = await page.request.post(url, { @@ -15,12 +30,47 @@ export async function loginAs(page: Page, username: string) { } export async function setNextAuthSession(page: Page, username: string, maxAge?: number) { - await postJson( - page, - '/api/e2e/session', - maxAge === undefined ? { username } : { username, maxAge }, - 'e2e nextauth session failed' - ) + const trimmed = username.trim() + if (!trimmed || !E2E_ALLOWED_USERS.has(trimmed)) { + throw new Error(`Invalid or disallowed e2e user: ${username}`) + } + + const secret = process.env.NEXTAUTH_SECRET || 'your-nextauth-secret' + const encryptionSecret = await getDerivedEncryptionKey(secret) + const tokenDuration = + typeof maxAge === 'number' && Number.isFinite(maxAge) + ? maxAge > 0 + ? maxAge + : -60 + : DEFAULT_MAX_AGE + + const now = Math.floor(Date.now() / 1000) + const token = await new EncryptJWT({ + email: `${trimmed}@example.com`, + isLeader: trimmed === 'e2e-user', + isMentee: trimmed === 'e2e-mentee', + isMentor: trimmed === 'e2e-mentor', + login: trimmed, + name: trimmed, + sub: trimmed, + }) + .setProtectedHeader({ alg: 'dir', enc: 'A256GCM' }) + .setIssuedAt() + .setExpirationTime(now + tokenDuration) + .encrypt(encryptionSecret) + + const frontend = new URL(process.env.FRONTEND_URL || 'http://localhost:3000') + await page.context().addCookies([ + { + domain: frontend.hostname, + httpOnly: true, + name: 'next-auth.session-token', + path: '/', + sameSite: 'Lax', + secure: false, + value: token, + }, + ]) } export async function setInvalidNextAuthSession(page: Page) { diff --git a/e2e/package.json b/e2e/package.json index 718916640b..91d3a64d7e 100644 --- a/e2e/package.json +++ b/e2e/package.json @@ -2,6 +2,7 @@ "dependencies": { "@playwright/test": "1.61.1", "dayjs": "1.11.21", + "jose": "4.15.9", "millify": "6.1.0" }, "engines": { diff --git a/e2e/pnpm-lock.yaml b/e2e/pnpm-lock.yaml index d35b4cf9ad..3848e3d2e2 100644 --- a/e2e/pnpm-lock.yaml +++ b/e2e/pnpm-lock.yaml @@ -18,6 +18,9 @@ importers: dayjs: specifier: 1.11.21 version: 1.11.21 + jose: + specifier: 4.15.9 + version: 4.15.9 millify: specifier: 6.1.0 version: 6.1.0 @@ -71,6 +74,9 @@ packages: resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} engines: {node: '>=8'} + jose@4.15.9: + resolution: {integrity: sha512-1vUQX+IdDMVPj4k8kOxgUqlcK518yluMuGZwqlr44FS1ppZB/5GWh4rZG89erpOBOJjU/OBsnCVFfapsRz6nEA==} + millify@6.1.0: resolution: {integrity: sha512-H/E3J6t+DQs/F2YgfDhxUVZz/dF8JXPPKTLHL/yHCcLZLtCXJDUaqvhJXQwqOVBvbyNn4T0WjLpIHd7PAw7fBA==} hasBin: true @@ -150,6 +156,8 @@ snapshots: is-fullwidth-code-point@3.0.0: {} + jose@4.15.9: {} + millify@6.1.0: dependencies: yargs: 17.7.2 diff --git a/frontend/src/app/api/e2e/session/route.ts b/frontend/src/app/api/e2e/session/route.ts deleted file mode 100644 index d4ff10330d..0000000000 --- a/frontend/src/app/api/e2e/session/route.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { NextResponse } from 'next/server' -import { encode } from 'next-auth/jwt' - -const E2E_ALLOWED_USERS = new Set(['e2e-user', 'e2e-mentor', 'e2e-mentee']) - -export async function POST(request: Request) { - if (!process.env.NEXT_PUBLIC_E2E_BACKEND_BASE_URL || !process.env.NEXTAUTH_SECRET) { - return new NextResponse(null, { status: 404 }) - } - - let body: { maxAge?: number; username?: string } - try { - body = (await request.json()) as { maxAge?: number; username?: string } - } catch { - return NextResponse.json({ ok: false }, { status: 400 }) - } - - const username = (body.username ?? '').trim() - if (!username || !E2E_ALLOWED_USERS.has(username)) { - return NextResponse.json({ ok: false }, { status: 400 }) - } - - const { maxAge } = body - const token = await encode({ - secret: process.env.NEXTAUTH_SECRET, - token: { - email: `${username}@example.com`, - isLeader: username === 'e2e-user', - isMentee: username === 'e2e-mentee', - isMentor: username === 'e2e-mentor', - login: username, - name: username, - sub: username, - }, - ...(typeof maxAge === 'number' && Number.isFinite(maxAge) - ? { maxAge: maxAge > 0 ? maxAge : -60 } - : {}), - }) - - const response = NextResponse.json({ ok: true }) - response.cookies.set('next-auth.session-token', token, { - httpOnly: true, - path: '/', - sameSite: 'lax', - secure: false, - }) - return response -} From 69121bfbd00e8337c613e9c1ff552ecd7957c9f5 Mon Sep 17 00:00:00 2001 From: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> Date: Wed, 2 Sep 2026 14:34:41 +0530 Subject: [PATCH 20/20] address bot ewview Signed-off-by: Mr-Rahul-Paul <179798584+Mr-Rahul-Paul@users.noreply.github.com> --- docker-compose/e2e/compose.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docker-compose/e2e/compose.yaml b/docker-compose/e2e/compose.yaml index 2cf6c60299..15da76e20a 100644 --- a/docker-compose/e2e/compose.yaml +++ b/docker-compose/e2e/compose.yaml @@ -109,6 +109,8 @@ services: backend: condition: service_healthy env_file: ../../frontend/.env.e2e + environment: + - NEXTAUTH_SECRET=${NEXTAUTH_SECRET:-your-nextauth-secret} healthcheck: interval: 5s retries: 10