diff --git a/.claude/settings.json b/.claude/settings.json index 9d221cb..7dc9f37 100644 --- a/.claude/settings.json +++ b/.claude/settings.json @@ -6,7 +6,29 @@ "Bash(JWT_SECRET=x JWT_REFRESH_SECRET=y MONGODB_URI=mongodb://localhost:27017 node -e ' *)", "Bash(node _smoke_userdata.js)", "Bash(node _smoke_email_send.js)", - "Bash(node _smoke_profile_upload.js)" + "Bash(node _smoke_profile_upload.js)", + "Bash(grep -i '\\\\.html$')", + "Bash(curl -s -o /dev/null -w \"backend health: %{http_code}\\\\n\" http://localhost:5000/api/v1/health)", + "Bash(curl -s -o /dev/null -w \"root: %{http_code}\\\\n\" http://localhost:5000/)", + "Bash(curl -s -o /dev/null -w \"/health: %{http_code}\\\\n\" http://localhost:5000/health)", + "Bash(curl -s -o /dev/null -w \"/api/v1: %{http_code}\\\\n\" http://localhost:5000/api/v1)", + "Bash(npx --yes playwright install --dry-run chromium)", + "Bash(cp /d/Upscaler-Frontend/src/styles/legacy-portal.css /c/Users/07kav/AppData/Local/Temp/claude/d--backend/10686d40-28ba-448e-825b-f09f4e650cc2/scratchpad/legacy-portal.css)", + "Bash(cp /d/Upscaler-Frontend/src/styles/theme-refined.css /c/Users/07kav/AppData/Local/Temp/claude/d--backend/10686d40-28ba-448e-825b-f09f4e650cc2/scratchpad/theme-refined.css)", + "Bash(cp /d/Upscaler-Frontend/src/styles/legacy-settings.css /c/Users/07kav/AppData/Local/Temp/claude/d--backend/10686d40-28ba-448e-825b-f09f4e650cc2/scratchpad/legacy-settings.css)", + "Read(//c/Users/07kav/AppData/Local/Temp/claude/d--backend/10686d40-28ba-448e-825b-f09f4e650cc2/scratchpad/**)", + "Bash(node inspect2.js)", + "Bash(node inspect3.js)", + "Bash(node inspect4.js)", + "Bash(node inspect5.js)", + "Bash(node scratch_make_roster.js)", + "Bash(rm scratch_make_roster.js)", + "Bash(node inspect7.js)", + "Bash(curl -s -o /dev/null -w \"proof file HTTP status: %{http_code}\\\\n\" \"http://localhost:5000/uploads/placement-proof/8c2b94e0-8854-46e4-840a-a45e11df4c27.png\")", + "Bash(node repro_login.js)", + "Bash(node repro_login2.js)", + "Bash(node repro_final_check.js)", + "Bash(gcloud config *)" ] } } diff --git a/.env.compose.example b/.env.compose.example new file mode 100644 index 0000000..eedac05 --- /dev/null +++ b/.env.compose.example @@ -0,0 +1,34 @@ +# Copy to `.env` in this directory — docker compose reads it automatically. +# cp .env.compose.example .env +# +# JWT_SECRET and JWT_REFRESH_SECRET have no defaults on purpose: compose +# refuses to start without them rather than booting with a guessable value. +# Generate each with: openssl rand -base64 48 + +JWT_SECRET= +JWT_REFRESH_SECRET= + +# Must be identical for node-api and ai-service (compose passes this one +# variable to both). Generate the same way as above. +AI_SERVICE_SHARED_SECRET=dev-only-shared-secret-change-me + +# Set to `production` to make ai-service refuse to boot while +# AI_SERVICE_SHARED_SECRET is still the public default above. +AI_SERVICE_ENV=development + +# Optional — AI features degrade gracefully (local fallback questions, +# unmodified resume text) when this is unset. +GROQ_API_KEY= + +# Optional — transactional email is skipped when unset. +BREVO_API_KEY= +BREVO_SENDER_EMAIL= +BREVO_SENDER_NAME=UpScaler-AI + +MONGODB_DB_NAME=upscaler_ai_node +CORS_ORIGINS=http://localhost:3000 +FRONTEND_URL=http://localhost:3000 + +# Baked into the frontend bundle at BUILD time — this is what the browser +# calls, so it must be reachable from the host, not a compose service name. +NEXT_PUBLIC_API_URL=http://localhost:5000/api/v1 diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000..e8b7cd9 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,10 @@ +# Revisions listed here are skipped by `git blame`. +# Enable locally with: +# git config blame.ignoreRevsFile .git-blame-ignore-revs +# (GitHub reads this file automatically.) +# +# Only pure-formatting commits belong here — never anything that changes +# behaviour, or blame will hide real changes. + +# style: apply black formatting across python-service +d621b5ecc01bce9792e452fcc1c5d8a54bb34484 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 547e703..d24c7ba 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -6,13 +6,16 @@ on: pull_request: branches: [main] +permissions: + contents: read + # Cancel superseded runs on the same branch/PR to save CI minutes. concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true env: - NODE_VERSION: "22" + NODE_VERSION: "24" PYTHON_VERSION: "3.12" jobs: @@ -73,64 +76,70 @@ jobs: - name: npm audit (fail on high/critical) run: npm audit --audit-level=high - # ── Python service ────────────────────────────────────────────────────── - python-lint: - name: python-service / lint + # ── AI Service (FastAPI) ──────────────────────────────────────────────── + # Same three-job split as node-api above, same reasoning. + ai-lint: + name: ai-service / lint runs-on: ubuntu-latest defaults: run: - working-directory: python-service + working-directory: ai-service steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: ${{ env.PYTHON_VERSION }} cache: "pip" - cache-dependency-path: python-service/requirements-dev.txt - - run: pip install -r requirements-dev.txt - - name: Lint (ruff) - run: ruff check . + cache-dependency-path: ai-service/requirements-lock.txt + - run: pip install -r requirements-lock.txt + - run: ruff check . - python-test: - name: python-service / test + ai-test: + name: ai-service / test runs-on: ubuntu-latest defaults: run: - working-directory: python-service + working-directory: ai-service steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: ${{ env.PYTHON_VERSION }} cache: "pip" - cache-dependency-path: python-service/requirements-dev.txt - - run: pip install -r requirements-dev.txt - - name: Test (pytest) - run: pytest -v + cache-dependency-path: ai-service/requirements-lock.txt + - run: pip install -r requirements-lock.txt + - run: pytest -v - python-audit: - name: python-service / dependency audit + ai-audit: + name: ai-service / dependency audit runs-on: ubuntu-latest defaults: run: - working-directory: python-service + working-directory: ai-service steps: - uses: actions/checkout@v4 - uses: actions/setup-python@v5 with: python-version: ${{ env.PYTHON_VERSION }} - cache: "pip" - cache-dependency-path: python-service/requirements.txt - - run: pip install -r requirements.txt - - name: pip-audit (dependency vulnerability scan) - run: | - pip install pip-audit - pip-audit -r requirements.txt + - run: pip install pip-audit + # PYSEC-2026-1325 (python-ecdsa, pulled in transitively by python-jose): + # a Minerva timing side-channel against ECDSA private-key signing + # operations. This service only ever signs/verifies HS256 (HMAC) + # tokens — see app/security.py — never ECDSA, and upstream has stated + # side-channel attacks are out of scope with no planned fix. Ignored + # here rather than left to silently fail the build on every run. + - name: pip-audit (fail on anything else) + run: pip-audit -r requirements-lock.txt --ignore-vuln PYSEC-2026-1325 docker-build: - name: Docker build (both services) - needs: [node-lint, node-test, node-audit, python-lint, python-test, python-audit] + name: Docker build (node-api) + needs: [node-lint, node-test, node-audit] runs-on: ubuntu-latest + services: + mongodb: + image: mongo:7 + ports: + - 27017:27017 steps: - uses: actions/checkout@v4 @@ -139,16 +148,10 @@ jobs: with: context: node-api push: false + load: true # image must land in the local Docker daemon — the smoke-test step below runs it tags: node-api:ci - - name: Build python-service image - uses: docker/build-push-action@v5 - with: - context: python-service - push: false - tags: python-service:ci - - # Scan built images for known CVEs before they ever reach a registry. + # Scan the built image for known CVEs before it ever reaches a registry. # Pinned to a commit SHA (not a mutable tag/branch like `@master`) — # third-party actions should be pinned the same way a dependency would # be, since an unpinned ref can start running different code with no @@ -160,38 +163,181 @@ jobs: severity: CRITICAL,HIGH exit-code: "1" - - name: Trivy scan — python-service + # A clean scan and a successful build both say nothing about whether + # the image actually *starts* — this is what would have caught the + # uploads/ directory permission bug (see Dockerfile's chown step): + # the build succeeded and Trivy was happy, but `docker run` crashed + # immediately on every attempt. Boots the real image against a real + # (ephemeral, in-CI) MongoDB and hits /health for real. + - name: Smoke test — container actually starts + run: | + docker run -d --rm --name node-api-smoke -p 5000:5000 \ + --network host \ + -e MONGODB_URI="mongodb://127.0.0.1:27017" \ + -e MONGODB_DB_NAME="ci_smoke" \ + -e JWT_SECRET="ci-smoke-secret" \ + -e JWT_REFRESH_SECRET="ci-smoke-refresh-secret" \ + -e CORS_ORIGINS="http://localhost:3000" \ + node-api:ci + for i in $(seq 1 15); do + if curl -sf http://127.0.0.1:5000/health; then echo "container is healthy"; exit 0; fi + sleep 1 + done + echo "Container never became healthy:" + docker logs node-api-smoke + exit 1 + + docker-build-ai: + name: Docker build (ai-service) + needs: [ai-lint, ai-test, ai-audit] + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Build ai-service image + uses: docker/build-push-action@v5 + with: + context: ai-service + push: false + load: true + tags: ai-service:ci + + - name: Trivy scan — ai-service uses: aquasecurity/trivy-action@ed142fd0673e97e23eac54620cfb913e5ce36c25 # v0.36.0 with: - image-ref: python-service:ci + image-ref: ai-service:ci severity: CRITICAL,HIGH exit-code: "1" - # Deploy is intentionally separate from the CI jobs above and gated on `main` - # only, so a red build can never reach Cloud Run. It's commented out end-to-end - # because no GCP project/Workload Identity Federation is wired up yet — see the - # GCP section of the audit for what needs to exist before this can be enabled. + # Same reasoning as node-api's smoke test above — a clean build and + # scan say nothing about whether the process actually starts under its + # non-root user with no config beyond the image's own defaults. + - name: Smoke test — container actually starts + run: | + docker run -d --rm --name ai-service-smoke -p 8001:8001 \ + -e AI_SERVICE_ENV=production \ + -e AI_SERVICE_SHARED_SECRET="ci-smoke-not-the-default-secret" \ + ai-service:ci + for i in $(seq 1 15); do + if curl -sf http://127.0.0.1:8001/health; then echo "container is healthy"; exit 0; fi + sleep 1 + done + echo "Container never became healthy:" + docker logs ai-service-smoke + exit 1 + + # ── Deploy ─────────────────────────────────────────────────────────────── + # Separate from the CI jobs above and gated on `main`, so a red build can + # never reach Cloud Run. + # + # This job is WRITTEN but not yet EXERCISED — no GCP project or Workload + # Identity Federation pool exists yet, so it has never actually run. The + # `if:` below is what keeps that honest: with the WIF_PROVIDER secret unset + # the job is skipped rather than failing every push with a red X. Setting + # the five secrets listed below is what turns it on; until then everything + # up to and including image build + smoke test still runs on every commit. # - # deploy: - # name: Deploy to Cloud Run - # needs: [docker-build] - # if: github.ref == 'refs/heads/main' - # runs-on: ubuntu-latest - # permissions: - # contents: read - # id-token: write # for Workload Identity Federation — no long-lived JSON keys - # steps: - # - uses: actions/checkout@v4 - # - uses: google-github-actions/auth@v2 - # with: - # workload_identity_provider: ${{ secrets.WIF_PROVIDER }} - # service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }} - # - uses: google-github-actions/setup-gcloud@v2 - # - run: | - # gcloud builds submit node-api --tag ${{ secrets.AR_REPO }}/node-api:${{ github.sha }} - # gcloud run deploy node-api-prod \ - # --image ${{ secrets.AR_REPO }}/node-api:${{ github.sha }} \ - # --region ${{ secrets.GCP_REGION }} \ - # --no-traffic --tag pr-${{ github.sha }} - # # promote traffic only after health check + smoke test pass, e.g.: - # # gcloud run services update-traffic node-api-prod --to-latest + # Required repository secrets: + # WIF_PROVIDER projects//locations/global/workloadIdentityPools//providers/ + # WIF_SERVICE_ACCOUNT deployer@.iam.gserviceaccount.com + # AR_REPO -docker.pkg.dev// + # GCP_REGION e.g. asia-south1 + # AI_SERVICE_SECRET_ID Secret Manager secret holding AI_SERVICE_SHARED_SECRET + deploy: + name: Deploy to Cloud Run + needs: [docker-build, docker-build-ai] + if: github.ref == 'refs/heads/main' && github.event_name == 'push' + runs-on: ubuntu-latest + permissions: + contents: read + id-token: write # Workload Identity Federation — no long-lived JSON keys + steps: + - uses: actions/checkout@v4 + + - name: Skip if GCP is not configured + id: gate + run: | + if [ -z "${{ secrets.WIF_PROVIDER }}" ]; then + echo "GCP secrets not set — skipping deploy (this is expected until GCP is wired up)." + echo "configured=false" >> "$GITHUB_OUTPUT" + else + echo "configured=true" >> "$GITHUB_OUTPUT" + fi + + - uses: google-github-actions/auth@v2 + if: steps.gate.outputs.configured == 'true' + with: + workload_identity_provider: ${{ secrets.WIF_PROVIDER }} + service_account: ${{ secrets.WIF_SERVICE_ACCOUNT }} + + - uses: google-github-actions/setup-gcloud@v2 + if: steps.gate.outputs.configured == 'true' + + - name: Configure Artifact Registry auth + if: steps.gate.outputs.configured == 'true' + run: gcloud auth configure-docker "${{ secrets.GCP_REGION }}-docker.pkg.dev" --quiet + + # Both images are pushed before either is deployed, so a registry + # failure can't leave node-api pointing at an ai-service tag that was + # never published. + - name: Build & push images + if: steps.gate.outputs.configured == 'true' + run: | + set -euo pipefail + docker build -t "${{ secrets.AR_REPO }}/node-api:${{ github.sha }}" node-api + docker build -t "${{ secrets.AR_REPO }}/ai-service:${{ github.sha }}" ai-service + docker push "${{ secrets.AR_REPO }}/node-api:${{ github.sha }}" + docker push "${{ secrets.AR_REPO }}/ai-service:${{ github.sha }}" + + # ai-service first: node-api's AI routes call it, and it holds no state, + # so rolling it forward ahead of its only caller is the safe order. + # --no-allow-unauthenticated keeps it off the public internet; only + # node-api's service account may invoke it. + - name: Deploy ai-service + if: steps.gate.outputs.configured == 'true' + run: | + gcloud run deploy ai-service \ + --image "${{ secrets.AR_REPO }}/ai-service:${{ github.sha }}" \ + --region "${{ secrets.GCP_REGION }}" \ + --no-allow-unauthenticated \ + --set-env-vars "AI_SERVICE_ENV=production" \ + --set-secrets "AI_SERVICE_SHARED_SECRET=${{ secrets.AI_SERVICE_SECRET_ID }}:latest" \ + --quiet + + # Deployed with --no-traffic so the new revision receives nothing until + # the smoke test below passes against its dedicated tag URL. This is the + # step that makes a bad revision a non-event rather than an outage. + - name: Deploy node-api (no traffic yet) + if: steps.gate.outputs.configured == 'true' + run: | + gcloud run deploy node-api \ + --image "${{ secrets.AR_REPO }}/node-api:${{ github.sha }}" \ + --region "${{ secrets.GCP_REGION }}" \ + --no-traffic --tag "sha-${GITHUB_SHA::7}" \ + --quiet + + - name: Smoke test the new revision + if: steps.gate.outputs.configured == 'true' + run: | + set -euo pipefail + URL=$(gcloud run services describe node-api \ + --region "${{ secrets.GCP_REGION }}" \ + --format="value(status.traffic.filter(tag:sha-${GITHUB_SHA::7}).extract(url))" | tr -d '[]') + echo "Probing $URL/health/ready" + for i in $(seq 1 20); do + if curl -sf "$URL/health/ready" | grep -q '"status":"ready"'; then + echo "new revision is ready"; exit 0 + fi + sleep 5 + done + echo "New revision never became ready — traffic NOT promoted." + exit 1 + + # Only reached if the smoke test passed. Until this runs, 100% of + # traffic is still on the previous revision. + - name: Promote traffic + if: steps.gate.outputs.configured == 'true' + run: | + gcloud run services update-traffic node-api \ + --region "${{ secrets.GCP_REGION }}" \ + --to-latest --quiet diff --git a/.gitignore b/.gitignore index 79c1fb3..f130cfe 100644 --- a/.gitignore +++ b/.gitignore @@ -6,5 +6,7 @@ __pycache__/ *.pyc *.pyo *.db -python-service/uploads/ node-api/uploads/ +node-api/coverage/ +.pytest_cache/ +.ruff_cache/ diff --git a/README.md b/README.md index d3ad670..b80965d 100644 --- a/README.md +++ b/README.md @@ -1,15 +1,33 @@ # UpScaler-AI Backend -This repository hosts **two independent backend services**, each in its own folder with its own -dependency manifest, env files, and `Dockerfile`: +This repository hosts two backend services: | Folder | Stack | Purpose | |---|---|---| -| [`python-service/`](python-service/README.md) | Python 3.12 / FastAPI / MongoDB | The original UpScaler-AI V2 API | -| [`node-api/`](node-api/README.md) | Node.js / Express / MongoDB | Clean-architecture REST API, RBAC, JWT + Google OAuth | +| [`node-api/`](node-api/README.md) | Node.js / Express / MongoDB | Clean-architecture REST API, RBAC, JWT + Google OAuth — sole source of truth for every route the frontend calls | +| [`ai-service/`](ai-service/README.md) | Python 3.12 / FastAPI / MongoDB | Narrow internal AI microservice (interview question generation, resume AI features) — called only by node-api over a JWT-authenticated internal API, never directly by the frontend | -Neither service's code was touched by the other's setup — see each folder's own `README.md` for -architecture and `REQUIREMENTS.md` for prerequisites. +See `node-api/README.md` for architecture and `node-api/REQUIREMENTS.md` for prerequisites. + +## Python service removal — and why `ai-service/` is not that service coming back + +The original `python-service/` (Python 3.12 / FastAPI / MongoDB) was removed. It predated +`node-api` and the two had drifted into duplicate, inconsistently-behaving implementations of the +same routes (auth, students, placements, etc.) against separate MongoDB databases. `node-api` had +already reached full functional parity with every endpoint the live frontend actually calls (see +`SECURITY_AUDIT.md` for the migration history), so keeping both running was pure duplicated +maintenance and deployment surface with no remaining benefit. Its git history is preserved; see the +commit that removed it for the full file list. Full migration/removal notes are appended to +`SECURITY_AUDIT.md`. Its leftover files on disk (a stale `.venv`, caches, `.env`) were never +cleaned up after that removal and have since been deleted for real. + +`ai-service/` is a deliberately smaller, newly-built replacement for one narrow slice of +functionality — the Groq-backed AI features (interview question generation, resume analysis/JD +matching/AI-suggest/parsing, assessment question generation) that used to live directly inside +node-api via `groq-sdk`. It owns no business data (no students/placements/auth — those stay in +node-api/MongoDB), is never called directly by the frontend, and every route requires a +JWT node-api mints per-request (see `ai-service/app/security.py`). It is not a restoration of the +old full-stack duplicate service described above. ## Recent updates @@ -48,4 +66,9 @@ architecture and `REQUIREMENTS.md` for prerequisites. |---|---| | Frontend | http://localhost:5173 (or 3000 for the existing Next.js app) | | Node API | http://localhost:5000 | -| Python service | http://localhost:8000 | + +The frontend's `NEXT_PUBLIC_API_URL` should point at `http://localhost:5000/api/v1`. Its +unset-env-var fallback (`src/lib/api.ts`, `D:\Upscaler-Frontend`) still defaults to port 8000 — a +leftover from when python-service owned that port. That fallback only matters if +`NEXT_PUBLIC_API_URL` is unset; update it to 5000 (or set the env var everywhere it's deployed) as +a follow-up. diff --git a/REQUIREMENTS.md b/REQUIREMENTS.md index dd47106..fa7c2c1 100644 --- a/REQUIREMENTS.md +++ b/REQUIREMENTS.md @@ -1,8 +1,13 @@ # Requirements — Index -This repo has two independent stacks, each with its own requirements doc: +This repo hosts two backend services: -- [`python-service/REQUIREMENTS.md`](python-service/REQUIREMENTS.md) — Python 3.12, MongoDB, `pip install -r requirements.txt` -- [`node-api/REQUIREMENTS.md`](node-api/REQUIREMENTS.md) — Node.js 18+, MongoDB, `npm install` +- [`node-api/REQUIREMENTS.md`](node-api/REQUIREMENTS.md) — Node.js 24+, MongoDB, `npm install` +- [`ai-service/README.md`](ai-service/README.md) — Python 3.12, `pip install -r requirements-dev.txt` + +node-api remains the sole source of truth and only entry point for every route the frontend calls; +ai-service is a narrow internal AI microservice node-api proxies to, never called directly by the +frontend. See `README.md` for details, including why this is not the same as the legacy +`python-service/` (a full duplicate backend) that was removed in an earlier pass. Frontend requirements live in the frontend's own repo: `D:\Upscaler-Frontend\REQUIREMENTS.md`. diff --git a/SECURITY_AUDIT.md b/SECURITY_AUDIT.md index 2c8315d..25414a8 100644 --- a/SECURITY_AUDIT.md +++ b/SECURITY_AUDIT.md @@ -437,3 +437,161 @@ Remaining follow-ups are hygiene, not blockers: - `starlette.testclient` warns that `httpx` support is deprecated in favor of `httpx2` — harmless today, but it will need attention when `httpx` is next bumped. - Re-run this audit on a cadence: `npm audit` / `pip-audit` catch *known* CVEs only, not zero-days. + +--- + +## 12. Third pass — full toolchain audit (`safety`, `black`, lock files) + +Re-verified the reported CI failure first. **`pip-audit` already passed with exit code 0** — the +Python scan failure was the one fixed in §3/§4; it is not still failing. This pass therefore +audited the wider toolchain rather than re-fixing resolved CVEs, and turned up two real defects. + +### 12a. Duplicate, unpinned `pydantic` declaration (found by `safety`, missed by `pip-audit`) + +`requirements.txt` declared pydantic twice: + +``` +pydantic==2.11.3 +pydantic[email] <- no version constraint +``` + +`safety` reported *"4 known vulnerabilities match the pydantic versions that could be installed +from your specifiers: `pydantic[email]>=0` (unpinned)"*. pip intersected the two constraints and +resolved 2.11.3 anyway, so **no vulnerable version was ever installed** — but the declaration was +both a duplicate and an open range, and the safety of the result depended on resolver behaviour +rather than on the manifest. Consolidated to a single pinned entry: + +| | Before | After | +|---|---|---| +| pydantic | `pydantic==2.11.3` + bare `pydantic[email]` | `pydantic[email]==2.11.3` | + +`safety` goes from *"0 reported, 4 ignored"* → *"0 reported, 0 ignored"*. This also satisfies the +"remove duplicate packages" requirement — it was the only duplicate in either service. + +### 12b. CRITICAL — the `python-test` CI job would have failed + +The workflow runs `pytest -v`. A **bare `pytest` does not put the working directory on +`sys.path`; only `python -m pytest` does.** Every local verification up to this point had used +`python -m pytest`, which masked the problem. Running the CI command exactly: + +``` +$ pytest -v +tests/test_security.py:8: in + from app.core.security import ( +E ModuleNotFoundError: No module named 'app' +Interrupted: 2 errors during collection +``` + +That job would have gone red on the first push. Fixed in configuration rather than by changing the +CI invocation, so the suite behaves identically either way: + +```toml +[tool.pytest.ini_options] +pythonpath = ["."] +testpaths = ["tests"] +``` + +This is the same class of defect as §9a (the undeclared Mongo drivers): **a divergence between the +developer's invocation and CI's**. Both were invisible until the exact production/CI command was +run in a clean environment. Every gate in this pass was subsequently re-run using the **bare +executables in a fresh venv**, not `python -m`. + +### 12c. `black` adopted as a separate commit + +`black --check` failed on 38 of 56 files (the project had never used black). Applying it is a +large, purely cosmetic diff, so — per your decision — it was kept as its own commit rather than +mixed into the security work: + +| Commit | Contents | +|---|---| +| `759183b` | `fix(deps)`: pydantic pin (§12a) | +| `d621b5e` | `style`: black reformat, 38 files — **no logic changes** | +| `e9ad48f` | `chore`: `.git-blame-ignore-revs` pointing at `d621b5e` | +| `638ee21` | `fix(ci)`: pytest `pythonpath` (§12b) | + +`black` is pinned in `requirements-dev.txt` (26.5.1) and enforced by a dedicated `black --check .` +step in the `python-lint` job. Its `line-length` is set to **100** in `pyproject.toml` to match +`[tool.ruff]`, so the two tools cannot disagree about wrapping — verified by running both after +the reformat. `git blame` skips the formatting commit via `.git-blame-ignore-revs`. + +### 12d. Lock files — nothing to regenerate + +| File | Status | +|---|---| +| `requirements.txt` | The real manifest — fully pinned (`==`) on every entry | +| `requirements-dev.txt` | Fully pinned; `-r requirements.txt` | +| `pyproject.toml` | Tool config only (`ruff`, `black`, `pytest`); `dependencies = []` | +| `uv.lock` | **Stub — locks nothing.** Contains exactly one `[[package]]` block: the `backend` project itself, with zero dependencies. A leftover from `uv init`. | +| `poetry.lock` | Does not exist | +| `Pipfile` / `Pipfile.lock` | Do not exist | + +Because `pyproject.toml` declares `dependencies = []`, `uv.lock` pins no third-party package and +**cannot carry a vulnerability**; no scanner reads it, and `uv` is not installed or used anywhere +in the build. There is nothing to regenerate. It is dead weight that misleadingly implies +uv-managed dependencies — worth either deleting or adopting uv properly, but that is a build-system +decision, so it was left in place rather than removed unilaterally. + +### 12e. `safety` not added to CI (your decision) + +`safety scan` (the modern command) **requires authentication** — it prompts for login and needs a +`SAFETY_API_KEY` secret for CI. `safety check` works unauthenticated against the open-source DB but +is officially deprecated and unsupported beyond 2024-06-01. Since `pip-audit` already gates CI +against the same PyPI/OSV advisory data and passes, `safety` was run **once, manually**, as an +independent cross-check (which is how §12a was found) but not wired into the pipeline. The +security policy was not weakened and no scan was disabled — `pip-audit` still fails the build on +any advisory. + +--- + +## 13. `python-service/` removed (backend stabilization pass) + +**Date:** 2026-08-01. **Scope:** repo-wide — `python-service/`, `.github/workflows/ci.yml`, +`README.md`, `REQUIREMENTS.md`. + +Everything in §1–12 above documents `python-service` as it existed; it is retained as history and +was **not rewritten**. This section records its removal, done as part of a broader backend +stabilization pass (token refresh, HR portal routing, change-password, role/pagination/upload +hardening — see the corresponding commit(s) around this date for the rest of that pass). + +**Why:** `node-api` and `python-service` had drifted into two independent implementations of +largely the same routes (auth, students, placements/jobs, resume, dashboard, etc.) against two +separate MongoDB databases (`upscaler_ai_node` vs `upscaler_ai`) — duplicated business logic, +duplicated auth, and duplicated maintenance/deployment surface (two Dockerfiles, two CI matrices) +for no behavioral benefit, since the live frontend only ever needed one backend to actually answer +its requests. + +**Verification before deletion:** every route prefix the frontend (`D:\Upscaler-Frontend`) calls +was cross-checked against `node-api/src/routes/index.js` and confirmed present and independently +functional — auth, users, institutions, departments, college-admins, companies, hr, faculty, +students(+profile), placements (aliased at `/jobs` too — see the HR portal fix in this same pass), +placement-applications, tests, test-assignments, results, notifications, resume, activity-logs, +user-data, placement-records, profile, ai, interviews, leaderboard, dashboard, batches, chat. +`node-api` already had its own independent MongoDB seed scripts (`db:seed-colleges`, +`db:seed-departments`, `db:seed-practice-tests`, `db:seed-question-bank`) — it was never dependent +on `python-service`'s database or `seed_mongo.py`. + +**What changed:** +- Deleted `python-service/` in full (git history preserves it — recoverable with + `git log --diff-filter=D -- python-service` if ever needed). +- `.github/workflows/ci.yml`: removed the `python-lint`/`python-test`/`python-audit` jobs, the + `PYTHON_VERSION` env var, the python-service Docker build step, and its Trivy scan. `docker-build` + now only builds/scans `node-api` and depends only on the three node-api jobs. + See §12e above — `pip-audit`'s CI gate no longer applies to this repo now that there is no + `requirements.txt` to check; dependency scanning is `node-audit`'s `npm audit` plus the Trivy + scan on the node-api image. +- `README.md` / `REQUIREMENTS.md`: updated to describe a single-service repo. + +**Known follow-ups, not done as part of this removal:** +- `seed_mongo.py` (deleted with the rest of `python-service/`) contained a hardcoded plaintext + MongoDB Atlas password, flagged back in §1 of this audit. Deleting the file does **not** rotate + that credential — it is still recoverable from git history. **Rotate it** if that has not already + happened. +- The frontend's `src/lib/api.ts` (`D:\Upscaler-Frontend`, a separate repo, intentionally not + modified here) falls back to `http://:8000/api/v1` — python-service's old port — when + `NEXT_PUBLIC_API_URL` is unset. This is a documentation/deployment-config gap, not a code gap: + wherever the frontend is actually deployed already sets `NEXT_PUBLIC_API_URL` to node-api's URL + (confirmed locally via `.env.local` → `http://localhost:5000/api/v1`), so this had no runtime + effect at the time of removal. Still worth fixing the fallback value in that repo as a follow-up + so it doesn't quietly point at a dead service. +- The Postgres-era `sql/schema.sql` mentioned in the root `README.md`'s history section was already + superseded before this pass (see that README) and needed no further action here. diff --git a/ai-service/.dockerignore b/ai-service/.dockerignore new file mode 100644 index 0000000..71a3315 --- /dev/null +++ b/ai-service/.dockerignore @@ -0,0 +1,12 @@ +.env +.env.* +!.env.example +.venv +__pycache__ +*.pyc +.pytest_cache +.ruff_cache +tests/ +.git +.gitignore +README.md diff --git a/ai-service/.env.example b/ai-service/.env.example new file mode 100644 index 0000000..4a6d8c6 --- /dev/null +++ b/ai-service/.env.example @@ -0,0 +1,30 @@ +# AI Service — Environment Configuration +# Copy to .env and fill in real values. Never commit .env. + +# Must match node-api's .env.node AI_SERVICE_SHARED_SECRET exactly, or every +# proxied call from node-api is rejected with 401. The value below is a +# publicly-known placeholder — the service REFUSES TO START with it when +# AI_SERVICE_ENV=production, since it is the only thing preventing anyone who +# can reach this port from forging a valid token. +AI_SERVICE_SHARED_SECRET=dev-only-shared-secret-change-me +SERVICE_PORT=8001 + +# "development" (default) or "production". Only controls how strict the +# startup checks are; no route behaviour branches on it. +AI_SERVICE_ENV=development + +# Optional — every AI route degrades gracefully when unset (fallback +# questions, unmodified resume text, or a clear 503 for the hard-fail routes). +GROQ_API_KEY= +GROQ_MODEL=llama-3.3-70b-versatile +GROQ_BASE_URL=https://api.groq.com/openai/v1 + +# Optional — observability logging only (ai_service_logs collection). This +# service owns no business data; leave unset to run with no Mongo dependency +# at all. +MONGODB_URI= +MONGODB_DB_NAME=upscaler_ai_service + +# Optional — only needed if this service is ever exposed to a browser +# directly (it isn't, by default: node-api is the sole caller). +CORS_ORIGINS= diff --git a/ai-service/Dockerfile b/ai-service/Dockerfile new file mode 100644 index 0000000..046716e --- /dev/null +++ b/ai-service/Dockerfile @@ -0,0 +1,24 @@ +# AI microservice image (FastAPI). Build context is this directory (ai-service/): +# docker build -t upscaler-ai-service -f ai-service/Dockerfile ai-service/ + +FROM python:3.12-slim AS runtime +WORKDIR /app + +# requirements-lock.txt is the exact resolved set (pip freeze) — mirrors +# node-api's package-lock.json discipline: reproducible installs, not +# whatever the >= ranges in requirements.txt happen to resolve to on the day +# of the build. +COPY requirements-lock.txt ./ +RUN pip install --no-cache-dir -r requirements-lock.txt + +COPY app ./app + +RUN useradd --create-home --uid 1000 appuser +USER appuser + +EXPOSE 8001 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8001/health').read()" || exit 1 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8001"] diff --git a/ai-service/README.md b/ai-service/README.md new file mode 100644 index 0000000..61fd003 --- /dev/null +++ b/ai-service/README.md @@ -0,0 +1,72 @@ +# UpScaler-AI — AI Service + +A narrow FastAPI microservice that owns every Groq-backed AI feature the platform uses: + +- Interview question generation (`POST /v1/interview/generate-questions`) +- Resume ATS analysis, JD matching, AI-suggest, parsing, and improvement (`POST /v1/resume/*`) +- Assessment/quiz question generation (`POST /v1/assessment/generate-questions`) + +## What this service is — and isn't + +- It owns **no business data**. Students, placements, resumes, and auth all stay in `node-api` / + MongoDB. This service is stateless except for an optional, best-effort `ai_service_logs` + collection (observability only — see `app/db.py`) that nothing else reads. +- It is **never called directly by the frontend**. `node-api` is the only caller, over a + JWT-authenticated internal API (see `app/security.py`) — every route requires a short-lived + HS256 token signed with a shared secret only `node-api` holds + (`utils/aiServiceClient.js` on that side). A browser has no way to reach this service or its + Groq quota. +- It is **not** a restoration of the old `python-service/` that was removed from this repo in an + earlier pass — that was a full duplicate backend (auth, students, placements, its own MongoDB + database). This service is a deliberately small slice of one concern. +- Every route degrades gracefully when `GROQ_API_KEY` isn't set: interview/assessment generation + fall back to a local placeholder, resume improvement returns the original text unchanged, and + the four remaining resume routes (analyze/match-jd/suggest/parse) return a clear `503` rather + than crashing. `node-api` never needs to know which case it got — see + `resumeBuilder.service.js#translateAiServiceError` on that side for how it maps this service's + responses back onto the exact error contract the frontend already handled before this service + existed. + +## Running locally + +```bash +python -m venv .venv +.venv/Scripts/activate # or source .venv/bin/activate on Linux/Mac +pip install -r requirements-dev.txt +cp .env.example .env # fill in AI_SERVICE_SHARED_SECRET to match node-api's .env.node +uvicorn app.main:app --reload --port 8001 +``` + +## Testing + +```bash +pytest -v # 32 tests — Groq calls are mocked via respx, nothing hits the real API +ruff check . +``` + +## Dependency locking + +`requirements.txt` declares loose (`>=`) version ranges for readability; `requirements-lock.txt` +is the exact resolved set (`pip freeze`), generated from a **clean, production-only** virtualenv +(`requirements.txt` alone, never `requirements-dev.txt`) and is what `Dockerfile` actually installs +from — the same reproducibility discipline as node-api's `package-lock.json`. Regenerate it with: + +```bash +python -m venv .venv-lock && .venv-lock/Scripts/pip install -r requirements.txt +.venv-lock/Scripts/pip freeze > requirements-lock.txt +rm -rf .venv-lock +``` + +## Deployment requirement + +`AI_SERVICE_SHARED_SECRET` must be set to a strong random value in any real +deployment, and to the **same** value in node-api's `AI_SERVICE_SHARED_SECRET`. +It is the only thing preventing anyone who can reach this service's port from +forging a token that passes verification on every route. Running with +`AI_SERVICE_ENV=production` while the secret is still the public default from +`.env.example` makes the service refuse to start, rather than silently serve in +that state. + +## Known accepted risk + +No accepted runtime dependency vulnerability exemptions are currently documented for this service. diff --git a/ai-service/app/__init__.py b/ai-service/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ai-service/app/config.py b/ai-service/app/config.py new file mode 100644 index 0000000..0692d17 --- /dev/null +++ b/ai-service/app/config.py @@ -0,0 +1,77 @@ +from functools import lru_cache + +from pydantic_settings import BaseSettings, SettingsConfigDict + +# Publicly known — matches .env.example. Never valid in production; see +# Settings.ai_service_shared_secret and main.py's startup check. +DEFAULT_DEV_SECRET = "dev-only-shared-secret-change-me" + + +class Settings(BaseSettings): + """Environment configuration for the AI microservice. + + Every field has a safe default so the service can boot (and its /health + endpoint stay up) even with nothing configured — individual features + (Groq-backed generation, Mongo-backed request logging) check their own + "is this configured" flag and degrade rather than crashing at import time. + """ + + model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore") + + # --- Service identity / internal auth --- + # Verified against the JWT node-api mints for every proxied call (see + # node-api's utils/aiServiceClient.js). Must match exactly on both sides. + # The default below is a publicly-known placeholder — main.py refuses to + # start with it when ai_service_env is "production", so a deployment that + # forgets to set this fails loudly at boot instead of silently accepting + # tokens anyone could forge. + ai_service_shared_secret: str = DEFAULT_DEV_SECRET + service_port: int = 8001 + + # Mirrors node-api's NODE_ENV. Only used to decide how strict the + # startup checks in main.py are — nothing else branches on it. + ai_service_env: str = "development" + + # --- Groq (LLM provider) --- + # Every AI-generation route checks `is_groq_configured` and degrades + # (fallback questions, unmodified resume text, or a clear 503) rather + # than crashing — matches node-api's groqClient.js philosophy exactly, + # since node-api used to own this same check before it moved here. + groq_api_key: str = "" + groq_model: str = "llama-3.3-70b-versatile" + groq_base_url: str = "https://api.groq.com/openai/v1" + + # --- MongoDB (optional: request/response observability logging only — + # this service owns no business data, node-api remains the single + # source of truth for students/resumes/interview attempts) --- + mongodb_uri: str = "" + mongodb_db_name: str = "upscaler_ai_service" + + # --- CORS: browsers never call this service directly (only node-api + # does, server-to-server), so this stays empty/closed by default. --- + cors_origins: str = "" + + @property + def is_production(self) -> bool: + return self.ai_service_env.lower() == "production" + + @property + def is_using_default_secret(self) -> bool: + return self.ai_service_shared_secret == DEFAULT_DEV_SECRET + + @property + def is_groq_configured(self) -> bool: + return bool(self.groq_api_key) + + @property + def is_mongo_configured(self) -> bool: + return bool(self.mongodb_uri) + + @property + def cors_origin_list(self) -> list[str]: + return [o.strip() for o in self.cors_origins.split(",") if o.strip()] + + +@lru_cache +def get_settings() -> Settings: + return Settings() diff --git a/ai-service/app/db.py b/ai-service/app/db.py new file mode 100644 index 0000000..4f0ae12 --- /dev/null +++ b/ai-service/app/db.py @@ -0,0 +1,72 @@ +import logging +import time +from contextlib import asynccontextmanager +from typing import Any + +from motor.motor_asyncio import AsyncIOMotorClient + +from app.config import get_settings + +logger = logging.getLogger("ai-service.db") + +_client: AsyncIOMotorClient | None = None + + +def get_mongo_client() -> AsyncIOMotorClient | None: + """Lazily created — most local/dev/test runs never set MONGODB_URI at + all (this service owns no business data; logging is a nice-to-have, not + a dependency any AI route should ever block on).""" + global _client + settings = get_settings() + if not settings.is_mongo_configured: + return None + if _client is None: + _client = AsyncIOMotorClient(settings.mongodb_uri, serverSelectionTimeoutMS=5000) + return _client + + +async def ping_mongo() -> bool: + client = get_mongo_client() + if client is None: + return False + try: + await client.admin.command("ping") + return True + except Exception as exc: # noqa: BLE001 — health check: any failure means "not ready", full stop + logger.warning("Mongo ping failed: %s", exc) + return False + + +async def log_ai_call(feature: str, *, success: bool, duration_ms: float, detail: dict[str, Any] | None = None) -> None: + """Best-effort observability only — a logging failure must never fail + (or even slow down materially) the actual AI request it's describing. + Node-api keeps the real system of record for anything user-facing.""" + client = get_mongo_client() + if client is None: + return + settings = get_settings() + try: + db = client[settings.mongodb_db_name] + await db.ai_service_logs.insert_one( + { + "feature": feature, + "success": success, + "duration_ms": duration_ms, + "detail": detail or {}, + "at": time.time(), + } + ) + except Exception as exc: # noqa: BLE001 + logger.warning("Failed to write ai_service_logs entry: %s", exc) + + +@asynccontextmanager +async def timed_log(feature: str): + start = time.perf_counter() + detail: dict[str, Any] = {} + try: + yield detail + await log_ai_call(feature, success=True, duration_ms=(time.perf_counter() - start) * 1000, detail=detail) + except Exception: + await log_ai_call(feature, success=False, duration_ms=(time.perf_counter() - start) * 1000, detail=detail) + raise diff --git a/ai-service/app/groq_client.py b/ai-service/app/groq_client.py new file mode 100644 index 0000000..569fec8 --- /dev/null +++ b/ai-service/app/groq_client.py @@ -0,0 +1,60 @@ +import json +import logging + +import httpx + +from app.config import get_settings + +logger = logging.getLogger("ai-service.groq") + + +class GroqError(Exception): + """Raised when a configured Groq call fails outright (network error, + non-2xx response, or unparseable JSON when jsonResponse was requested). + Callers translate this into a 502 — distinct from "not configured", + which routes check for themselves via settings.is_groq_configured + before ever reaching this module.""" + + +async def groq_complete( + system_prompt: str, + user_prompt: str, + *, + temperature: float = 0.7, + max_tokens: int = 1024, + json_response: bool = False, +) -> str | dict: + settings = get_settings() + body = { + "model": settings.groq_model, + "temperature": temperature, + "max_tokens": max_tokens, + "messages": [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": user_prompt}, + ], + } + if json_response: + body["response_format"] = {"type": "json_object"} + + try: + async with httpx.AsyncClient(timeout=30.0) as client: + resp = await client.post( + f"{settings.groq_base_url}/chat/completions", + headers={"Authorization": f"Bearer {settings.groq_api_key}"}, + json=body, + ) + resp.raise_for_status() + data = resp.json() + except httpx.HTTPError as exc: + logger.error("Groq request failed: %s", exc) + raise GroqError(str(exc)) from exc + + content = data["choices"][0]["message"]["content"] or "" + if not json_response: + return content + try: + return json.loads(content) + except json.JSONDecodeError as exc: + logger.error("Groq returned non-JSON content for a json_response request: %s", exc) + raise GroqError(f"Groq returned invalid JSON: {exc}") from exc diff --git a/ai-service/app/main.py b/ai-service/app/main.py new file mode 100644 index 0000000..01cbfa5 --- /dev/null +++ b/ai-service/app/main.py @@ -0,0 +1,92 @@ +import logging + +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import JSONResponse +from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi.errors import RateLimitExceeded +from slowapi.middleware import SlowAPIMiddleware +from slowapi.util import get_remote_address + +from app.config import get_settings +from app.db import ping_mongo +from app.routers import assessment, interview, resume + +logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(name)s: %(message)s") +logger = logging.getLogger("ai-service") + +settings = get_settings() + +# The shared secret is the ONLY thing standing between this service and +# anyone who can reach its port — a forged token signed with the publicly +# known default would pass verification on every route. Fail at boot rather +# than serve in that state; a log warning alone would be too easy to miss. +if settings.is_using_default_secret: + if settings.is_production: + raise RuntimeError( + "AI_SERVICE_SHARED_SECRET is still the public default value. Set it to a strong " + "random secret (and the same value in node-api's AI_SERVICE_SHARED_SECRET) before " + "running with AI_SERVICE_ENV=production." + ) + logger.warning( + "Using the default development AI_SERVICE_SHARED_SECRET — fine locally, but set a real " + "secret before deploying (AI_SERVICE_ENV=production refuses to start without one)." + ) + +# The only caller of this service is node-api (server-to-server, JWT-authed — +# see security.py); rate limiting here is defense in depth against a leaked +# shared secret or a misbehaving node-api instance retrying in a hot loop, +# not a substitute for node-api's own user-facing rate limits. +limiter = Limiter(key_func=get_remote_address, default_limits=["60/minute"]) + +app = FastAPI(title="UpScaler-AI — AI Service", version="1.0.0") +app.state.limiter = limiter +app.add_middleware(SlowAPIMiddleware) +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + +if settings.cors_origin_list: + app.add_middleware( + CORSMiddleware, + allow_origins=settings.cors_origin_list, + allow_credentials=True, + allow_methods=["*"], + allow_headers=["*"], + ) + +app.include_router(interview.router) +app.include_router(resume.router) +app.include_router(assessment.router) + + +@app.get("/health") +async def health(): + return {"status": "ok"} + + +@app.get("/health/ready") +async def health_ready(): + # Fresh lookup, not the module-level `settings` snapshot from import + # time — get_settings() is cached but the cache is invalidated whenever + # config changes (e.g. tests patch env vars and clear the cache), and a + # health check that only ever reports the process's start-of-day config + # would be misleading in any environment where that matters. + settings = get_settings() + mongo_ok = await ping_mongo() if settings.is_mongo_configured else None + checks = { + "groq": {"status": "configured" if settings.is_groq_configured else "not_configured"}, + "mongo": ( + {"status": "ok" if mongo_ok else "error"} if settings.is_mongo_configured else {"status": "not_configured"} + ), + } + # "ready" only requires the process to be able to serve requests — Groq + # being unconfigured is a valid, intentionally-supported deployment state + # (every route degrades gracefully rather than depending on it), so it + # never fails readiness. An unreachable Mongo *would* fail it, but Mongo + # here is logging-only, so even that is deliberately not load-bearing. + return JSONResponse(content={"status": "ready", "checks": checks}) + + +@app.exception_handler(Exception) +async def unhandled_exception_handler(request: Request, exc: Exception): + logger.error("Unhandled exception on %s %s: %s", request.method, request.url.path, exc) + return JSONResponse(status_code=500, content={"error": "Internal server error"}) diff --git a/ai-service/app/routers/__init__.py b/ai-service/app/routers/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ai-service/app/routers/assessment.py b/ai-service/app/routers/assessment.py new file mode 100644 index 0000000..6425790 --- /dev/null +++ b/ai-service/app/routers/assessment.py @@ -0,0 +1,60 @@ +import logging + +from fastapi import APIRouter, Depends, HTTPException, status + +from app.config import get_settings +from app.db import timed_log +from app.groq_client import GroqError, groq_complete +from app.schemas.assessment import ( + AssessmentQuestion, + GenerateAssessmentQuestionsRequest, + GenerateAssessmentQuestionsResponse, +) +from app.security import verify_service_token + +logger = logging.getLogger("ai-service.assessment") +router = APIRouter(prefix="/v1/assessment", tags=["assessment"], dependencies=[Depends(verify_service_token)]) + +# Ported verbatim from node-api's test.service.js (itself ported from +# python-service's assessments.py#generate_assessment_questions) — a single +# placeholder question, not a full 10-question set, matching both prior +# implementations exactly. +QUESTION_GEN_SYSTEM_PROMPT = """You are an expert curriculum designer. Generate exactly 10 +multiple-choice questions as a raw JSON object: {"questions": [...]}. Each item must have +"question" (string), "options" (array of exactly 4 strings), and "correct_answer" (string, +must match one of the options). No markdown, no text outside the JSON.""" + + +def _fallback_question(payload: GenerateAssessmentQuestionsRequest) -> GenerateAssessmentQuestionsResponse: + return GenerateAssessmentQuestionsResponse( + questions=[ + AssessmentQuestion( + question=f"Sample {payload.difficulty} {payload.type} question for {payload.title}", + options=["A", "B", "C", "D"], + correct_answer="A", + ) + ] + ) + + +@router.post("/generate-questions", response_model=GenerateAssessmentQuestionsResponse) +async def generate_questions(payload: GenerateAssessmentQuestionsRequest): + settings = get_settings() + if not settings.is_groq_configured: + return _fallback_question(payload) + + user_prompt = f'Title: "{payload.title}"\nType: {payload.type}\nDifficulty: {payload.difficulty}' + + async with timed_log("assessment.generate_questions") as detail: + detail["title"] = payload.title + try: + result = await groq_complete( + QUESTION_GEN_SYSTEM_PROMPT, user_prompt, temperature=0.7, max_tokens=2048, json_response=True + ) + except GroqError as exc: + logger.error("Assessment question generation failed: %s", exc) + raise HTTPException( + status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Failed to generate questions: {exc}" + ) from exc + + return GenerateAssessmentQuestionsResponse(questions=result.get("questions", []) if isinstance(result, dict) else []) diff --git a/ai-service/app/routers/interview.py b/ai-service/app/routers/interview.py new file mode 100644 index 0000000..d63da12 --- /dev/null +++ b/ai-service/app/routers/interview.py @@ -0,0 +1,70 @@ +import logging +import random + +from fastapi import APIRouter, Depends + +from app.config import get_settings +from app.db import timed_log +from app.groq_client import GroqError, groq_complete +from app.schemas.interview import GenerateQuestionsRequest, InterviewQuestion +from app.security import verify_service_token + +logger = logging.getLogger("ai-service.interview") +router = APIRouter(prefix="/v1/interview", tags=["interview"], dependencies=[Depends(verify_service_token)]) + +# Ported verbatim from node-api's interview.service.js (itself ported from +# python-service's interviews.py#generate_questions) — only ever tagged +# "technical", matching both prior implementations exactly. +FALLBACK_QUESTION_POOL = [ + "What are the key differences between React and Angular?", + "Explain the concept of closures in JavaScript.", + "How would you optimize a slow-performing database query?", + "Describe a time you had to resolve a conflict within your team.", + "What is the difference between TCP and UDP?", +] + + +def _fallback_questions(company: str, role: str) -> list[InterviewQuestion]: + pool = FALLBACK_QUESTION_POOL * 2 + random.shuffle(pool) + return [ + InterviewQuestion( + id=i + 1, + text=f"[{company.upper()} - {role.upper()}] {q}", + time_limit_seconds=60, + type="technical", + ) + for i, q in enumerate(pool) + ] + + +@router.post("/generate-questions", response_model=list[InterviewQuestion]) +async def generate_questions(payload: GenerateQuestionsRequest): + settings = get_settings() + + if not settings.is_groq_configured: + return _fallback_questions(payload.company, payload.role) + + system_prompt = f"You are an expert technical interviewer at {payload.company} hiring for a {payload.role} position." + user_prompt = ( + "Generate exactly 10 interview questions for this specific role and company.\n" + "Make them realistic, challenging, and a mix of technical (7) and behavioral (3) questions.\n" + 'Return the result as a raw JSON object with a single key "questions" containing a list of strings.' + ) + + async with timed_log("interview.generate_questions") as detail: + detail["role"] = payload.role + detail["company"] = payload.company + try: + result = await groq_complete(system_prompt, user_prompt, temperature=0.7, max_tokens=1024, json_response=True) + questions = result.get("questions", []) if isinstance(result, dict) else [] + if len(questions) < 10: + raise GroqError("LLM did not return enough questions") + except GroqError as exc: + logger.error("Interview question generation failed, falling back: %s", exc) + return _fallback_questions(payload.company, payload.role) + + return [ + InterviewQuestion(id=i + 1, text=q, time_limit_seconds=60, type="technical" if i < 7 else "behavioral") + for i, q in enumerate(questions[:10]) + ] diff --git a/ai-service/app/routers/resume.py b/ai-service/app/routers/resume.py new file mode 100644 index 0000000..2b1e3a8 --- /dev/null +++ b/ai-service/app/routers/resume.py @@ -0,0 +1,210 @@ +import json +import logging + +from fastapi import APIRouter, Depends, HTTPException, status + +from app.config import get_settings +from app.db import timed_log +from app.groq_client import GroqError, groq_complete +from app.schemas.resume import ( + AiSuggestRequest, + AiSuggestResponse, + AnalyzeRequest, + ImproveResumeRequest, + ImproveResumeResponse, + MatchJdRequest, + ParseResumeRequest, +) +from app.security import verify_service_token + +logger = logging.getLogger("ai-service.resume") +router = APIRouter(prefix="/v1/resume", tags=["resume"], dependencies=[Depends(verify_service_token)]) + +NOT_CONFIGURED_DETAIL = "GROQ_API_KEY is not configured" + + +def _require_groq() -> None: + if not get_settings().is_groq_configured: + # 503, not 500 — this is a missing optional dependency of the AI + # service, not a bug in it. node-api's aiServiceClient.js maps this + # specific status back to the same ApiError.internal('GROQ_API_KEY is + # not configured') the frontend already handled before this endpoint + # existed, so the contract callers see is unchanged. + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail=NOT_CONFIGURED_DETAIL) + + +ANALYZE_SYSTEM_PROMPT = "You are an expert ATS Resume Analyzer. Return a valid JSON object only, no markdown." +ANALYZE_KEYS = """Required JSON keys: +- "score": integer 0-100 (ATS score) +- "readability": integer 0-100 +- "section_completeness": list of {"section", "score" (0-100), "status" ("Complete"|"Needs Info"|"Missing")} +- "keywords_analyzed": integer count of key industry keywords found +- "missing_keywords": list of strings +- "formatting_issues": list of strings +- "contact_validation": list of strings +- "skills_gap": list of strings +- "experience_quality": string +- "suggestions": list of strings""" + + +@router.post("/analyze") +async def analyze(payload: AnalyzeRequest): + _require_groq() + user_prompt = ( + f"Analyze the following resume and return a highly detailed, professional feedback report.\n\n" + f"{ANALYZE_KEYS}\n\nResume Data:\n{json.dumps(payload.resume, indent=2)}" + ) + async with timed_log("resume.analyze"): + try: + return await groq_complete(ANALYZE_SYSTEM_PROMPT, user_prompt, temperature=0.3, max_tokens=2048, json_response=True) + except GroqError as exc: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"ATS Analysis failed: {exc}") from exc + + +MATCH_SYSTEM_PROMPT = "You are an expert recruiter. Return a valid JSON object only, no markdown." +MATCH_KEYS = """Required JSON keys: +- "match_percentage": integer 0-100 +- "ats_match_status": string ("Highly Compatible"|"Moderately Compatible"|"Low Compatibility") +- "matching_skills": list of strings +- "missing_keywords": list of strings +- "suggested_skills": list of strings +- "missing_experience": string +- "recommended_certifications": list of strings +- "recommended_projects": list of strings +- "overall_evaluation": string""" + + +@router.post("/match-jd") +async def match_job_description(payload: MatchJdRequest): + _require_groq() + user_prompt = ( + "Compare the candidate's resume with the provided Job Description. Return a detailed compatibility report.\n\n" + f"{MATCH_KEYS}\n\nJob Description:\n{payload.jd_text}\n\nCandidate Resume:\n{json.dumps(payload.resume, indent=2)}" + ) + async with timed_log("resume.match_jd"): + try: + return await groq_complete(MATCH_SYSTEM_PROMPT, user_prompt, temperature=0.3, max_tokens=2048, json_response=True) + except GroqError as exc: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"JD Matching failed: {exc}") from exc + + +AI_SUGGEST_SYSTEM_PROMPT = "You are a professional ATS resume writer. Help the student optimize their profile." + + +@router.post("/suggest", response_model=AiSuggestResponse) +async def ai_suggest(payload: AiSuggestRequest): + _require_groq() + + action = payload.action + content = payload.content or "" + if action == "summary": + user_prompt = ( + "Based on the following content, write a concise, compelling, and professional resume summary " + f"(maximum 3 sentences):\n{content}" + ) + elif action == "rewrite": + user_prompt = ( + "Rewrite the following description or bullet point using strong action verbs, professional style, " + f"and making it ATS-friendly. Maintain all facts and metrics:\n{content}" + ) + elif action == "skills": + user_prompt = ( + f"Given the student's department or role '{content}', suggest a structured list of technical skills, " + "tools, and soft skills they should include on their resume. Return a JSON object with keys " + "'technical', 'tools', 'soft'." + ) + elif action == "grammar": + user_prompt = ( + "Correct any grammar or spelling mistakes in the following text. Preserve the original meaning and " + f"formatting. Return only the corrected text:\n{content}" + ) + elif action in ("cover_letter", "interview_prep"): + resume_data = json.dumps(payload.resume) if payload.resume else content + if action == "cover_letter": + user_prompt = ( + "Write a professional, tailored Cover Letter based on this Job Description and the student's " + f"resume.\n\nJob Description:\n{payload.jd_text or ''}\n\nStudent Resume:\n{resume_data}" + ) + else: + user_prompt = ( + "Generate 5 tailored technical and behavioral interview preparation questions and guidelines " + f"based on the student's resume.\n\nStudent Resume:\n{resume_data}" + ) + else: + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="Invalid action type") + + async with timed_log("resume.ai_suggest") as detail: + detail["action"] = action + try: + output = await groq_complete(AI_SUGGEST_SYSTEM_PROMPT, user_prompt, temperature=0.5, max_tokens=1500) + except GroqError as exc: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"AI suggestion failed: {exc}") from exc + + output = output.strip() + if action == "skills": + start, end = output.find("{"), output.rfind("}") + 1 + if start != -1 and end != 0: + try: + return AiSuggestResponse(result=json.loads(output[start:end])) + except json.JSONDecodeError: + pass # fall through with the raw text — same best-effort behavior as node-api/python + return AiSuggestResponse(result=output) + + +PARSE_SYSTEM_PROMPT = "You are an expert resume parsing tool. Return a valid JSON object only, no markdown." +PARSE_SCHEMA = """- "personal": { "name": "", "email": "", "phone": "", "linkedin": "", "github": "", "portfolio": "", "address": "", "role": "" } +- "objective": "" +- "education": [ { "institution": "", "degree": "", "year": "", "score": "" } ] +- "experience": [ { "role": "", "company": "", "duration": "", "description": "" } ] +- "projects": [ { "title": "", "description": "", "link": "", "technologies": "" } ] +- "skills": [ { "name": "", "category": "technical" | "soft" } ] +- "certifications": [ { "name": "", "issuer": "", "year": "" } ]""" + + +@router.post("/parse") +async def parse_resume_text(payload: ParseResumeRequest): + _require_groq() + user_prompt = ( + "Extract the candidate information from the following text into structured JSON fields matching this " + f"exact key structure:\n\n{PARSE_SCHEMA}\n\nText content:\n{payload.text}" + ) + async with timed_log("resume.parse"): + try: + return await groq_complete(PARSE_SYSTEM_PROMPT, user_prompt, temperature=0.1, max_tokens=2048, json_response=True) + except GroqError as exc: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Resume parsing failed: {exc}") from exc + + +IMPROVE_SYSTEM_PROMPT = "You are an expert Resume Writer and Career Coach." + + +@router.post("/improve", response_model=ImproveResumeResponse) +async def improve_resume_text(payload: ImproveResumeRequest): + settings = get_settings() + if not settings.is_groq_configured: + return ImproveResumeResponse( + objective=payload.objective, + education=payload.education, + skills=payload.skills, + experience=payload.experience, + message="GROQ_API_KEY not configured. Returning original text.", + ) + + user_prompt = ( + "Improve the following resume sections to make them sound professional, impactful, and ATS-friendly.\n" + "Do not add new facts, just rewrite the existing information better.\n" + 'Return the result as a raw JSON object with keys: "objective", "education", "skills", "experience".\n\n' + f"Objective: {payload.objective}\nEducation: {payload.education}\nSkills: {payload.skills}\nExperience: {payload.experience}" + ) + async with timed_log("resume.improve"): + try: + result = await groq_complete(IMPROVE_SYSTEM_PROMPT, user_prompt, temperature=0.7, max_tokens=1024, json_response=True) + except GroqError as exc: + raise HTTPException(status_code=status.HTTP_502_BAD_GATEWAY, detail=f"Failed to improve resume: {exc}") from exc + + return ImproveResumeResponse( + objective=result.get("objective", payload.objective), + education=result.get("education", payload.education), + skills=result.get("skills", payload.skills), + experience=result.get("experience", payload.experience), + ) diff --git a/ai-service/app/schemas/__init__.py b/ai-service/app/schemas/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ai-service/app/schemas/assessment.py b/ai-service/app/schemas/assessment.py new file mode 100644 index 0000000..cfff986 --- /dev/null +++ b/ai-service/app/schemas/assessment.py @@ -0,0 +1,17 @@ +from pydantic import BaseModel + + +class GenerateAssessmentQuestionsRequest(BaseModel): + title: str = "Assessment" + type: str = "general" + difficulty: str = "medium" + + +class AssessmentQuestion(BaseModel): + question: str + options: list[str] + correct_answer: str + + +class GenerateAssessmentQuestionsResponse(BaseModel): + questions: list[AssessmentQuestion] diff --git a/ai-service/app/schemas/interview.py b/ai-service/app/schemas/interview.py new file mode 100644 index 0000000..ada2daa --- /dev/null +++ b/ai-service/app/schemas/interview.py @@ -0,0 +1,13 @@ +from pydantic import BaseModel, Field + + +class GenerateQuestionsRequest(BaseModel): + role: str = Field(min_length=1) + company: str = "general" + + +class InterviewQuestion(BaseModel): + id: int + text: str + time_limit_seconds: int = 60 + type: str diff --git a/ai-service/app/schemas/resume.py b/ai-service/app/schemas/resume.py new file mode 100644 index 0000000..421aae6 --- /dev/null +++ b/ai-service/app/schemas/resume.py @@ -0,0 +1,43 @@ +from typing import Any + +from pydantic import BaseModel + + +class AnalyzeRequest(BaseModel): + resume: dict[str, Any] + + +class MatchJdRequest(BaseModel): + resume: dict[str, Any] + jd_text: str + + +class AiSuggestRequest(BaseModel): + action: str + section: str | None = None + content: str | None = None + jd_text: str | None = None + resume: dict[str, Any] | None = None + + +class AiSuggestResponse(BaseModel): + result: str | dict[str, Any] + + +class ParseResumeRequest(BaseModel): + text: str + + +class ImproveResumeRequest(BaseModel): + objective: str = "" + education: str = "" + skills: str = "" + experience: str = "" + + +class ImproveResumeResponse(BaseModel): + objective: str + education: str + skills: str + experience: str + message: str | None = None diff --git a/ai-service/app/security.py b/ai-service/app/security.py new file mode 100644 index 0000000..ffc4e56 --- /dev/null +++ b/ai-service/app/security.py @@ -0,0 +1,44 @@ +from datetime import datetime, timedelta, timezone + +import jwt +from fastapi import Header, HTTPException, status +from jwt.exceptions import InvalidTokenError + +from app.config import get_settings + +ALGORITHM = "HS256" +ISSUER = "node-api" +AUDIENCE = "ai-service" + + +def mint_service_token(shared_secret: str, ttl_seconds: int = 60) -> str: + """Used only by tests / local tooling — node-api has its own minting + logic in utils/aiServiceClient.js that must stay byte-for-byte compatible + with the claims verified below (iss/aud/exp).""" + now = datetime.now(timezone.utc) + payload = {"iss": ISSUER, "aud": AUDIENCE, "iat": now, "exp": now + timedelta(seconds=ttl_seconds)} + return jwt.encode(payload, shared_secret, algorithm=ALGORITHM) + + +async def verify_service_token(authorization: str | None = Header(default=None)) -> None: + """Every AI route depends on this — only node-api (the sole caller, + server-to-server, never a browser) holds the shared secret needed to + produce a token that verifies here. A missing/invalid/expired token is + always 401, with no distinction given in the response about which check + failed, so a caller fishing for the shared secret learns nothing more + from an expired-but-otherwise-valid token than from a garbage one.""" + settings = get_settings() + if not authorization or not authorization.startswith("Bearer "): + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Missing bearer token") + + token = authorization.removeprefix("Bearer ").strip() + try: + jwt.decode( + token, + settings.ai_service_shared_secret, + algorithms=[ALGORITHM], + audience=AUDIENCE, + issuer=ISSUER, + ) + except InvalidTokenError as exc: + raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="Invalid or expired token") from exc diff --git a/ai-service/pytest.ini b/ai-service/pytest.ini new file mode 100644 index 0000000..82bc8d1 --- /dev/null +++ b/ai-service/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +asyncio_mode = auto +pythonpath = . diff --git a/ai-service/requirements-dev.txt b/ai-service/requirements-dev.txt new file mode 100644 index 0000000..079295f --- /dev/null +++ b/ai-service/requirements-dev.txt @@ -0,0 +1,6 @@ +-r requirements.txt +pytest>=8.3 +pytest-asyncio>=0.24 +respx>=0.21 +ruff>=0.7 +pip-audit>=2.7 diff --git a/ai-service/requirements-lock.txt b/ai-service/requirements-lock.txt new file mode 100644 index 0000000..a021356 --- /dev/null +++ b/ai-service/requirements-lock.txt @@ -0,0 +1,32 @@ +annotated-doc==0.0.5 +annotated-types==0.8.0 +anyio==4.14.2 +certifi==2026.7.22 +click==8.4.2 +Deprecated==1.3.1 +dnspython==2.8.0 +fastapi==0.141.1 +h11==0.16.0 +httpcore==1.0.9 +httptools==0.8.0 +httpx==0.28.1 +idna==3.18 +limits==5.8.0 +motor==3.7.1 +packaging==26.2 +pydantic==2.13.4 +pydantic-settings==2.14.2 +pydantic_core==2.46.4 +PyJWT==2.13.0 +pymongo==4.17.0 +python-dotenv==1.2.2 +PyYAML==6.0.3 +slowapi==0.1.10 +starlette==1.3.1 +typing-inspection==0.4.2 +typing_extensions==4.16.0 +uvicorn==0.52.1 +uvloop==0.22.1 +watchfiles==1.2.0 +websockets==17.0.1 +wrapt==2.3.0 diff --git a/ai-service/requirements.txt b/ai-service/requirements.txt new file mode 100644 index 0000000..6b0b671 --- /dev/null +++ b/ai-service/requirements.txt @@ -0,0 +1,10 @@ +fastapi>=0.115 +uvicorn[standard]>=0.32 +pydantic>=2.9 +pydantic-settings>=2.6 +motor>=3.6 +pymongo>=4.10 +PyJWT>=2.10 +slowapi>=0.1.9 +httpx>=0.27 +python-dotenv>=1.0 diff --git a/ai-service/tests/__init__.py b/ai-service/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ai-service/tests/conftest.py b/ai-service/tests/conftest.py new file mode 100644 index 0000000..fe27fad --- /dev/null +++ b/ai-service/tests/conftest.py @@ -0,0 +1,41 @@ +import pytest +from fastapi.testclient import TestClient + +from app.config import get_settings +from app.main import app +from app.security import mint_service_token + +TEST_SECRET = "test-shared-secret" + + +@pytest.fixture(autouse=True) +def _settings_override(monkeypatch): + """Every test runs with a known, fixed shared secret and Groq + unconfigured by default — individual tests opt into a fake Groq key via + the groq_configured fixture below rather than this one, so "Groq is off" + stays the default assumption matching most real deployments.""" + monkeypatch.setenv("AI_SERVICE_SHARED_SECRET", TEST_SECRET) + monkeypatch.setenv("GROQ_API_KEY", "") + monkeypatch.setenv("MONGODB_URI", "") + get_settings.cache_clear() + yield + get_settings.cache_clear() + + +@pytest.fixture +def client(): + return TestClient(app) + + +@pytest.fixture +def auth_headers(): + token = mint_service_token(TEST_SECRET) + return {"Authorization": f"Bearer {token}"} + + +@pytest.fixture +def groq_configured(monkeypatch): + monkeypatch.setenv("GROQ_API_KEY", "fake-test-key") + get_settings.cache_clear() + yield + get_settings.cache_clear() diff --git a/ai-service/tests/test_assessment.py b/ai-service/tests/test_assessment.py new file mode 100644 index 0000000..e85560e --- /dev/null +++ b/ai-service/tests/test_assessment.py @@ -0,0 +1,52 @@ +import json + +import respx +from httpx import Response + +GROQ_URL = "https://api.groq.com/openai/v1/chat/completions" + + +def test_generate_questions_falls_back_when_groq_unconfigured(client, auth_headers): + resp = client.post( + "/v1/assessment/generate-questions", + json={"title": "Data Structures", "type": "quiz", "difficulty": "hard"}, + headers=auth_headers, + ) + assert resp.status_code == 200 + body = resp.json() + assert len(body["questions"]) == 1 + q = body["questions"][0] + assert "Data Structures" in q["question"] + assert q["options"] == ["A", "B", "C", "D"] + assert q["correct_answer"] == "A" + + +def test_generate_questions_uses_defaults(client, auth_headers): + resp = client.post("/v1/assessment/generate-questions", json={}, headers=auth_headers) + assert resp.status_code == 200 + assert "Assessment" in resp.json()["questions"][0]["question"] + + +@respx.mock +def test_generate_questions_uses_groq_when_configured(client, auth_headers, groq_configured): + questions = [{"question": "What is a stack?", "options": ["A", "B", "C", "D"], "correct_answer": "B"}] + respx.post(GROQ_URL).mock( + return_value=Response(200, json={"choices": [{"message": {"content": json.dumps({"questions": questions})}}]}) + ) + resp = client.post( + "/v1/assessment/generate-questions", json={"title": "DS", "type": "quiz", "difficulty": "easy"}, headers=auth_headers + ) + assert resp.status_code == 200 + assert resp.json()["questions"] == questions + + +@respx.mock +def test_generate_questions_returns_502_when_groq_fails(client, auth_headers, groq_configured): + respx.post(GROQ_URL).mock(return_value=Response(500)) + resp = client.post("/v1/assessment/generate-questions", json={}, headers=auth_headers) + assert resp.status_code == 502 + + +def test_requires_auth(client): + resp = client.post("/v1/assessment/generate-questions", json={}) + assert resp.status_code == 401 diff --git a/ai-service/tests/test_health.py b/ai-service/tests/test_health.py new file mode 100644 index 0000000..43a3147 --- /dev/null +++ b/ai-service/tests/test_health.py @@ -0,0 +1,26 @@ +def test_health(client): + resp = client.get("/health") + assert resp.status_code == 200 + assert resp.json() == {"status": "ok"} + + +def test_health_ready_with_groq_unconfigured(client): + resp = client.get("/health/ready") + assert resp.status_code == 200 + body = resp.json() + assert body["status"] == "ready" + assert body["checks"]["groq"]["status"] == "not_configured" + assert body["checks"]["mongo"]["status"] == "not_configured" + + +def test_health_ready_with_groq_configured(client, groq_configured): + resp = client.get("/health/ready") + assert resp.json()["checks"]["groq"]["status"] == "configured" + + +def test_health_does_not_require_auth(client): + # Load balancer / orchestrator health probes never carry a service JWT — + # matches node-api's own /health being registered before its rate + # limiter/auth stack for the same reason. + resp = client.get("/health") + assert resp.status_code == 200 diff --git a/ai-service/tests/test_interview.py b/ai-service/tests/test_interview.py new file mode 100644 index 0000000..558dfda --- /dev/null +++ b/ai-service/tests/test_interview.py @@ -0,0 +1,84 @@ +import json + +import respx +from httpx import ConnectError, Request, Response + + +def test_generate_questions_falls_back_when_groq_unconfigured(client, auth_headers): + resp = client.post( + "/v1/interview/generate-questions", + json={"role": "Backend Engineer", "company": "Acme"}, + headers=auth_headers, + ) + assert resp.status_code == 200 + questions = resp.json() + assert len(questions) == 10 + assert all(q["type"] == "technical" for q in questions) + assert all("ACME" in q["text"] and "BACKEND ENGINEER" in q["text"] for q in questions) + + +def test_generate_questions_defaults_company_to_general(client, auth_headers): + resp = client.post("/v1/interview/generate-questions", json={"role": "QA Engineer"}, headers=auth_headers) + assert resp.status_code == 200 + assert all("GENERAL" in q["text"] for q in resp.json()) + + +def test_generate_questions_rejects_missing_role(client, auth_headers): + resp = client.post("/v1/interview/generate-questions", json={}, headers=auth_headers) + assert resp.status_code == 422 + + +@respx.mock +def test_generate_questions_uses_groq_when_configured(client, auth_headers, groq_configured): + fake_questions = [f"Question {i}" for i in range(1, 11)] + respx.post("https://api.groq.com/openai/v1/chat/completions").mock( + return_value=Response( + 200, + json={"choices": [{"message": {"content": json.dumps({"questions": fake_questions})}}]}, + ) + ) + + resp = client.post( + "/v1/interview/generate-questions", + json={"role": "Backend Engineer", "company": "Acme"}, + headers=auth_headers, + ) + assert resp.status_code == 200 + questions = resp.json() + assert [q["text"] for q in questions] == fake_questions + assert [q["type"] for q in questions[:7]] == ["technical"] * 7 + assert [q["type"] for q in questions[7:]] == ["behavioral"] * 3 + + +@respx.mock +def test_generate_questions_falls_back_when_groq_returns_too_few(client, auth_headers, groq_configured): + respx.post("https://api.groq.com/openai/v1/chat/completions").mock( + return_value=Response( + 200, + json={"choices": [{"message": {"content": json.dumps({"questions": ["only one"]})}}]}, + ) + ) + + resp = client.post( + "/v1/interview/generate-questions", + json={"role": "Backend Engineer", "company": "Acme"}, + headers=auth_headers, + ) + assert resp.status_code == 200 + assert len(resp.json()) == 10 # fell back to the local pool, never errored + + +@respx.mock +def test_generate_questions_falls_back_when_groq_unreachable(client, auth_headers, groq_configured): + def _refuse(request: Request): + raise ConnectError("connection refused", request=request) + + respx.post("https://api.groq.com/openai/v1/chat/completions").mock(side_effect=_refuse) + + resp = client.post( + "/v1/interview/generate-questions", + json={"role": "Backend Engineer", "company": "Acme"}, + headers=auth_headers, + ) + assert resp.status_code == 200 + assert len(resp.json()) == 10 diff --git a/ai-service/tests/test_resume.py b/ai-service/tests/test_resume.py new file mode 100644 index 0000000..68eb30e --- /dev/null +++ b/ai-service/tests/test_resume.py @@ -0,0 +1,141 @@ +import json + +import respx +from httpx import Response + +GROQ_URL = "https://api.groq.com/openai/v1/chat/completions" + + +def _mock_groq(content): + respx.post(GROQ_URL).mock(return_value=Response(200, json={"choices": [{"message": {"content": json.dumps(content)}}]})) + + +# --- hard-fail routes: analyze, match-jd, suggest, parse --- + + +def test_analyze_returns_503_when_groq_unconfigured(client, auth_headers): + resp = client.post("/v1/resume/analyze", json={"resume": {"skills": ["Python"]}}, headers=auth_headers) + assert resp.status_code == 503 + assert resp.json()["detail"] == "GROQ_API_KEY is not configured" + + +def test_match_jd_returns_503_when_groq_unconfigured(client, auth_headers): + resp = client.post( + "/v1/resume/match-jd", json={"resume": {}, "jd_text": "Looking for a backend engineer"}, headers=auth_headers + ) + assert resp.status_code == 503 + + +def test_ai_suggest_returns_503_when_groq_unconfigured(client, auth_headers): + resp = client.post("/v1/resume/suggest", json={"action": "grammar", "content": "helo wrld"}, headers=auth_headers) + assert resp.status_code == 503 + + +def test_parse_returns_503_when_groq_unconfigured(client, auth_headers): + resp = client.post("/v1/resume/parse", json={"text": "John Doe, Software Engineer"}, headers=auth_headers) + assert resp.status_code == 503 + + +@respx.mock +def test_analyze_returns_groq_report_when_configured(client, auth_headers, groq_configured): + report = {"score": 82, "readability": 90, "suggestions": ["Add more metrics"]} + _mock_groq(report) + resp = client.post("/v1/resume/analyze", json={"resume": {"skills": ["Python"]}}, headers=auth_headers) + assert resp.status_code == 200 + assert resp.json() == report + + +@respx.mock +def test_match_jd_returns_groq_report_when_configured(client, auth_headers, groq_configured): + report = {"match_percentage": 77, "ats_match_status": "Moderately Compatible"} + _mock_groq(report) + resp = client.post( + "/v1/resume/match-jd", json={"resume": {}, "jd_text": "Looking for a backend engineer"}, headers=auth_headers + ) + assert resp.status_code == 200 + assert resp.json() == report + + +@respx.mock +def test_analyze_returns_502_when_groq_fails(client, auth_headers, groq_configured): + respx.post(GROQ_URL).mock(return_value=Response(500, json={"error": "server error"})) + resp = client.post("/v1/resume/analyze", json={"resume": {}}, headers=auth_headers) + assert resp.status_code == 502 + + +# --- ai-suggest: verifies the jd_text field actually reaches the prompt +# (node-api's old aiSuggest() destructured `jdText` while the request body +# only ever carried `jd_text` — always undefined; fixed as part of this +# migration, and this test guards against reintroducing that mismatch) --- + + +@respx.mock +def test_ai_suggest_cover_letter_includes_jd_text(client, auth_headers, groq_configured): + captured = {} + + def _capture(request): + captured["body"] = json.loads(request.content) + return Response(200, json={"choices": [{"message": {"content": "Dear Hiring Manager..."}}]}) + + respx.post(GROQ_URL).mock(side_effect=_capture) + + resp = client.post( + "/v1/resume/suggest", + json={"action": "cover_letter", "jd_text": "We need a Python developer", "resume": {"skills": ["Python"]}}, + headers=auth_headers, + ) + assert resp.status_code == 200 + sent_prompt = captured["body"]["messages"][1]["content"] + assert "We need a Python developer" in sent_prompt + assert "undefined" not in sent_prompt + + +@respx.mock +def test_ai_suggest_skills_parses_embedded_json(client, auth_headers, groq_configured): + respx.post(GROQ_URL).mock( + return_value=Response( + 200, + json={ + "choices": [ + {"message": {"content": 'Here you go:\n{"technical": ["Python"], "tools": [], "soft": []}\nEnjoy!'}} + ] + }, + ) + ) + resp = client.post("/v1/resume/suggest", json={"action": "skills", "content": "Backend"}, headers=auth_headers) + assert resp.status_code == 200 + assert resp.json()["result"] == {"technical": ["Python"], "tools": [], "soft": []} + + +def test_ai_suggest_rejects_invalid_action(client, auth_headers, groq_configured): + resp = client.post("/v1/resume/suggest", json={"action": "not-a-real-action"}, headers=auth_headers) + assert resp.status_code == 400 + + +# --- improve: the one soft-fallback route, must never hard-fail --- + + +def test_improve_soft_falls_back_when_groq_unconfigured(client, auth_headers): + resp = client.post( + "/v1/resume/improve", + json={"objective": "Original objective", "education": "", "skills": "", "experience": ""}, + headers=auth_headers, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["objective"] == "Original objective" + assert "not configured" in body["message"].lower() + + +@respx.mock +def test_improve_returns_groq_output_when_configured(client, auth_headers, groq_configured): + _mock_groq({"objective": "Improved objective", "education": "E", "skills": "S", "experience": "X"}) + resp = client.post( + "/v1/resume/improve", + json={"objective": "orig", "education": "E", "skills": "S", "experience": "X"}, + headers=auth_headers, + ) + assert resp.status_code == 200 + body = resp.json() + assert body["objective"] == "Improved objective" + assert body["message"] is None diff --git a/ai-service/tests/test_security.py b/ai-service/tests/test_security.py new file mode 100644 index 0000000..4eb9227 --- /dev/null +++ b/ai-service/tests/test_security.py @@ -0,0 +1,88 @@ +import pytest + +from app.security import mint_service_token + + +def test_missing_token_is_rejected(client): + resp = client.post("/v1/interview/generate-questions", json={"role": "Backend Engineer"}) + assert resp.status_code == 401 + + +def test_garbage_token_is_rejected(client): + resp = client.post( + "/v1/interview/generate-questions", + json={"role": "Backend Engineer"}, + headers={"Authorization": "Bearer not-a-real-token"}, + ) + assert resp.status_code == 401 + + +def test_token_signed_with_wrong_secret_is_rejected(client): + bad_token = mint_service_token("some-other-secret-entirely") + resp = client.post( + "/v1/interview/generate-questions", + json={"role": "Backend Engineer"}, + headers={"Authorization": f"Bearer {bad_token}"}, + ) + assert resp.status_code == 401 + + +def test_expired_token_is_rejected(client): + expired = mint_service_token("test-shared-secret", ttl_seconds=-10) + resp = client.post( + "/v1/interview/generate-questions", + json={"role": "Backend Engineer"}, + headers={"Authorization": f"Bearer {expired}"}, + ) + assert resp.status_code == 401 + + +def test_valid_token_is_accepted(client, auth_headers): + resp = client.post( + "/v1/interview/generate-questions", + json={"role": "Backend Engineer"}, + headers=auth_headers, + ) + assert resp.status_code == 200 + + +# --- Startup guard: the service must never run in production with the +# publicly-known default shared secret (see main.py). --- + + +def test_default_secret_is_rejected_in_production(monkeypatch): + import importlib + + from app.config import DEFAULT_DEV_SECRET, get_settings + + monkeypatch.setenv("AI_SERVICE_SHARED_SECRET", DEFAULT_DEV_SECRET) + monkeypatch.setenv("AI_SERVICE_ENV", "production") + get_settings.cache_clear() + + import app.main + + with pytest.raises(RuntimeError, match="still the public default"): + importlib.reload(app.main) + + # Restore a sane module state for any test importing app.main afterwards. + monkeypatch.setenv("AI_SERVICE_ENV", "development") + get_settings.cache_clear() + importlib.reload(app.main) + + +def test_real_secret_is_accepted_in_production(monkeypatch): + import importlib + + from app.config import get_settings + + monkeypatch.setenv("AI_SERVICE_SHARED_SECRET", "a-genuinely-set-production-secret") + monkeypatch.setenv("AI_SERVICE_ENV", "production") + get_settings.cache_clear() + + import app.main + + importlib.reload(app.main) # must not raise + + monkeypatch.setenv("AI_SERVICE_ENV", "development") + get_settings.cache_clear() + importlib.reload(app.main) diff --git a/cloudbuild.yaml b/cloudbuild.yaml new file mode 100644 index 0000000..d1f265c --- /dev/null +++ b/cloudbuild.yaml @@ -0,0 +1,102 @@ +# Cloud Build config for this monorepo. There is intentionally NO Dockerfile +# at the repo root — node-api/ and ai-service/ each have their own, built +# with their own directory as build context. Every step below is explicit +# about which Dockerfile + context it uses so Cloud Build never falls back +# to guessing /workspace/Dockerfile. +# +# Trigger setup (GCP Console → Cloud Build → Triggers): +# Configuration: "Cloud Build configuration file (yaml or json)" +# Location: cloudbuild.yaml (repo root) +# Do NOT use the "Dockerfile" build-type option for this repo — that option +# has no field for per-service subdirectories on some trigger UIs and is what +# produces the `lstat /workspace/Dockerfile: no such file or directory` error. +# +# Required substitution variables (set in the trigger, Console → Edit trigger +# → Substitution variables): +# _REGION e.g. asia-south1 +# _AR_REPO Artifact Registry repo name, e.g. upscaler-images +# +# Required IAM roles for the Cloud Build service account +# (@cloudbuild.gserviceaccount.com): +# roles/artifactregistry.writer - push images +# roles/run.admin - deploy to Cloud Run +# roles/iam.serviceAccountUser - act as the Cloud Run runtime service account +# roles/logging.logWriter - already granted by default + +substitutions: + _REGION: asia-south1 + _AR_REPO: upscaler-images + +options: + logging: CLOUD_LOGGING_ONLY + +timeout: 1200s + +steps: + # ---- node-api ---- + - id: build-node-api + name: gcr.io/cloud-builders/docker + args: + - build + - -f + - node-api/Dockerfile + - -t + - ${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_AR_REPO}/node-api:${SHORT_SHA} + - -t + - ${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_AR_REPO}/node-api:latest + - node-api + + - id: push-node-api + name: gcr.io/cloud-builders/docker + args: + - push + - --all-tags + - ${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_AR_REPO}/node-api + waitFor: [build-node-api] + + # ---- ai-service ---- + - id: build-ai-service + name: gcr.io/cloud-builders/docker + args: + - build + - -f + - ai-service/Dockerfile + - -t + - ${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_AR_REPO}/ai-service:${SHORT_SHA} + - -t + - ${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_AR_REPO}/ai-service:latest + - ai-service + + - id: push-ai-service + name: gcr.io/cloud-builders/docker + args: + - push + - --all-tags + - ${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_AR_REPO}/ai-service + waitFor: [build-ai-service] + + # ---- deploy: ai-service first (node-api calls it; it holds no state) ---- + - id: deploy-ai-service + name: gcr.io/google.com/cloudsdk/cloud-sdk + entrypoint: gcloud + args: + - run + - deploy + - ai-service + - --image=${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_AR_REPO}/ai-service:${SHORT_SHA} + - --region=${_REGION} + - --no-allow-unauthenticated + - --quiet + waitFor: [push-ai-service] + + - id: deploy-node-api + name: gcr.io/google.com/cloudsdk/cloud-sdk + entrypoint: gcloud + args: + - run + - deploy + - node-api + - --image=${_REGION}-docker.pkg.dev/${PROJECT_ID}/${_AR_REPO}/node-api:${SHORT_SHA} + - --region=${_REGION} + - --quiet + waitFor: [push-node-api, deploy-ai-service] diff --git a/deploy/CREDENTIAL-ROTATION.md b/deploy/CREDENTIAL-ROTATION.md new file mode 100644 index 0000000..272c37e --- /dev/null +++ b/deploy/CREDENTIAL-ROTATION.md @@ -0,0 +1,112 @@ +# Credential rotation runbook + +Nothing here can be automated from this repo — every step needs console access +to a third-party service. Ordered by urgency. + +--- + +## 1. `KAVI123` on `cluster0.wdl8tpt.mongodb.net` — **EXPOSED, revoke now** + +**Status: publicly readable on GitHub right now.** + +A plaintext Atlas password for this user was committed in +`python-service/seed_mongo.py`. The file was later deleted, but deletion does +not remove it from history — it remains in commits `0274160` and `1f68007`, +both reachable from `origin/main` and three other branches on +`github.com/KIVOX-dev/backend`, which is a **public** repository. + +Assume this credential is compromised. Anyone who has cloned or scraped the +repo has it. + +1. **Atlas → Database Access → delete the `KAVI123` user.** Delete, don't + rotate — if nothing still uses it (nothing in this working tree references + it), removal is cleaner than a new password. +2. **Atlas → check that cluster's activity/access logs** for connections you + don't recognise, and review its collections for unexpected writes or drops. +3. Only after (1): decide whether to purge history. Rewriting with + `git filter-repo` and force-pushing invalidates every existing clone and + open PR, and does **not** un-leak anything already scraped — which is why + revoking comes first and rewriting is optional cleanup, not the fix. + +> If that cluster is already decommissioned, confirm it in Atlas rather than +> assuming — a forgotten free-tier cluster left running with a leaked password +> is exactly the case worth checking. + +--- + +## 2. `globaleducationgv_db_user` on `cluster0.o90ixew.mongodb.net` — rotate + +**Status: never committed.** I scanned every commit in the repository; neither +this cluster's hostname nor the Brevo key below appears in any of them. Its +only exposure is `node-api/.env.node` on local machines, which is gitignored. + +This is hygiene, not an incident — but it has been flagged across several +review rounds and is the live production credential. + +1. Atlas → Database Access → edit the user → **Edit Password** → autogenerate. +2. Update `MONGODB_URI` in `node-api/.env.node` locally. +3. Update it in whatever runs production (Cloud Run: put it in Secret Manager + and reference it with `--set-secrets`, rather than `--set-env-vars`, so the + value never appears in a revision's plaintext config). +4. Restart node-api and confirm `GET /health/ready` reports `mongo: ok`. + +--- + +## 3. Brevo API key — rotate + +**Status: never committed** (verified by the same full-history scan). Present +in `node-api/.env.node` only. + +1. Brevo → SMTP & API → API Keys → delete the existing key, create a new one. +2. Update `BREVO_API_KEY` in `.env.node` and in production secrets. +3. Confirm: registering a user should log a sent verification email rather + than `Email not sent — Brevo is not configured`. + +--- + +## 4. Google OAuth client secret — rotate if it was ever shared + +`GOOGLE_CLIENT_SECRET` lives in `.env.node`. Not committed. Rotate through +Google Cloud Console → APIs & Services → Credentials if it has ever been +pasted into a chat, ticket, or shared document. + +--- + +## 5. Application secrets you control — regenerate at will + +These are values *you choose*, not third-party credentials, so they can be +rotated with no external console: + +| Variable | Where | Effect of rotating | +|---|---|---| +| `JWT_SECRET` | node-api | **Logs every user out immediately.** Every access token becomes invalid. | +| `JWT_REFRESH_SECRET` | node-api | Invalidates all refresh tokens; users must log in again. | +| `AI_SERVICE_SHARED_SECRET` | node-api **and** ai-service | Must change in **both at once** — a mismatch makes every AI request 401 until they agree. | + +Generate each with `openssl rand -base64 48`. + +Rotate the two JWT secrets during a low-traffic window, since the entire user +base is signed out the moment they change. `AI_SERVICE_SHARED_SECRET` has no +user-visible effect if both services are updated together — with +`docker-compose.yml` both read the same variable, so they cannot drift. + +--- + +## Verifying nothing new has leaked + +The full-history scan used to produce the statuses above: + +```bash +# Replace the pattern with the hostname / key prefix you want to check. +for c in $(git rev-list --all); do + git grep -q "PATTERN" "$c" -- 2>/dev/null && echo "FOUND in $c" +done +``` + +Do **not** pipe that `git grep` into `head` — the pipeline's exit status +becomes `head`'s, which is always 0, and every commit reports as a match. That +false positive is what initially made the current cluster look compromised +when it was not. + +Longer term, a `gitleaks` or `trufflehog` job in CI catches this at push time +rather than during a review months later. diff --git a/deploy/nginx.conf b/deploy/nginx.conf new file mode 100644 index 0000000..89dfbf7 --- /dev/null +++ b/deploy/nginx.conf @@ -0,0 +1,50 @@ +# Single entry point in front of the node-api replicas. Docker's embedded DNS +# resolves `node-api` to every healthy replica's IP and nginx round-robins +# across them, so consecutive requests genuinely land on different instances — +# which is the point: it makes shared-state bugs (rate limits, WebSocket +# broadcast) reproducible locally instead of only under real load. + +events { + worker_connections 1024; +} + +http { + # Re-resolve the service name periodically. Docker hands out new IPs when a + # replica restarts, and nginx would otherwise cache the dead one until reload. + resolver 127.0.0.11 valid=10s ipv6=off; + + upstream node_api { + server node-api:5000; + keepalive 32; + } + + server { + listen 80; + + # WebSocket chat needs the upgrade handshake forwarded verbatim; without + # these three headers the connection is silently downgraded to plain HTTP + # and the client reconnect-loops with no useful error. + location / { + proxy_pass http://node_api; + proxy_http_version 1.1; + proxy_set_header Upgrade $http_upgrade; + proxy_set_header Connection $connection_upgrade; + + proxy_set_header Host $host; + # node-api trusts X-Forwarded-For for rate-limit keying, so it must be + # set here or every request appears to come from nginx's own IP and the + # whole cluster shares one rate-limit bucket. + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + proxy_read_timeout 300s; + proxy_connect_timeout 5s; + } + } + + map $http_upgrade $connection_upgrade { + default upgrade; + '' close; + } +} diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..5d47fe9 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,128 @@ +# Full local stack: frontend + node-api (x2) + ai-service + MongoDB + Redis. +# +# docker compose up --build +# frontend http://localhost:3000 +# node-api http://localhost:5000 +# +# node-api runs TWO replicas behind nothing in particular, deliberately: with +# REDIS_URL set they share rate-limit counters and broadcast chat to each +# other over Redis pub/sub. Drop REDIS_URL and each replica silently becomes +# its own island — chat messages stop crossing between them and rate limits +# become per-instance. Running two here makes that class of bug reproducible +# locally instead of only in production. + +name: upscaler-ai + +services: + mongo: + image: mongo:7 + restart: unless-stopped + volumes: + - mongo-data:/data/db + healthcheck: + test: ["CMD", "mongosh", "--quiet", "--eval", "db.adminCommand('ping').ok"] + interval: 10s + timeout: 5s + retries: 10 + start_period: 20s + + redis: + image: redis:7-alpine + restart: unless-stopped + command: ["redis-server", "--appendonly", "yes"] + volumes: + - redis-data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 10s + timeout: 3s + retries: 10 + + ai-service: + build: + context: ./ai-service + restart: unless-stopped + environment: + # Must match node-api's AI_SERVICE_SHARED_SECRET exactly. Both read the + # same shell variable so they cannot drift; the service refuses to boot + # under AI_SERVICE_ENV=production with the public default. + AI_SERVICE_SHARED_SECRET: ${AI_SERVICE_SHARED_SECRET:-dev-only-shared-secret-change-me} + AI_SERVICE_ENV: ${AI_SERVICE_ENV:-development} + GROQ_API_KEY: ${GROQ_API_KEY:-} + MONGODB_URI: mongodb://mongo:27017 + MONGODB_DB_NAME: upscaler_ai_service + depends_on: + mongo: + condition: service_healthy + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request;urllib.request.urlopen('http://127.0.0.1:8001/health').read()"] + interval: 15s + timeout: 5s + retries: 5 + start_period: 15s + + node-api: + build: + context: ./node-api + restart: unless-stopped + deploy: + replicas: 2 + # No host port binding on purpose — two replicas cannot both claim + # host :5000. They sit behind the nginx service below, which is the + # single published entry point. + expose: + - "5000" + environment: + NODE_ENV: production + PORT: 5000 + MONGODB_URI: mongodb://mongo:27017 + MONGODB_DB_NAME: ${MONGODB_DB_NAME:-upscaler_ai_node} + # Without this every replica keeps its own rate-limit counters and its + # own in-memory chat broadcaster — see the note at the top of this file. + REDIS_URL: redis://redis:6379 + JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in .env before starting} + JWT_REFRESH_SECRET: ${JWT_REFRESH_SECRET:?set JWT_REFRESH_SECRET in .env before starting} + CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost:3000} + AI_SERVICE_URL: http://ai-service:8001 + AI_SERVICE_SHARED_SECRET: ${AI_SERVICE_SHARED_SECRET:-dev-only-shared-secret-change-me} + FRONTEND_URL: ${FRONTEND_URL:-http://localhost:3000} + BREVO_API_KEY: ${BREVO_API_KEY:-} + BREVO_SENDER_EMAIL: ${BREVO_SENDER_EMAIL:-} + BREVO_SENDER_NAME: ${BREVO_SENDER_NAME:-UpScaler-AI} + depends_on: + mongo: + condition: service_healthy + redis: + condition: service_healthy + ai-service: + condition: service_healthy + + # Round-robins across the node-api replicas so a single stable URL + # (localhost:5000) reaches every instance — see deploy/nginx.conf. + nginx: + image: nginx:1.27-alpine + restart: unless-stopped + ports: + - "${API_PORT:-5000}:80" + volumes: + - ./deploy/nginx.conf:/etc/nginx/nginx.conf:ro + depends_on: + - node-api + + frontend: + build: + context: ../Upscaler-Frontend + args: + # Baked into the client bundle at build time, so this is the URL the + # BROWSER uses — localhost, not the compose service name. Changing it + # requires a rebuild, not a restart. + NEXT_PUBLIC_API_URL: ${NEXT_PUBLIC_API_URL:-http://localhost:5000/api/v1} + restart: unless-stopped + ports: + - "${FRONTEND_PORT:-3000}:3000" + depends_on: + - nginx + +volumes: + mongo-data: + redis-data: diff --git a/node-api/.dockerignore b/node-api/.dockerignore index e6021cc..bc144a0 100644 --- a/node-api/.dockerignore +++ b/node-api/.dockerignore @@ -8,3 +8,4 @@ uploads/ README.md REQUIREMENTS.md docs/ +.env diff --git a/node-api/.env.node.example b/node-api/.env.node.example index 9e1884e..7a48b32 100644 --- a/node-api/.env.node.example +++ b/node-api/.env.node.example @@ -38,6 +38,19 @@ GOOGLE_CLIENT_SECRET=your-google-oauth-client-secret # --- Rate Limiting --- RATE_LIMIT_WINDOW_MS=900000 RATE_LIMIT_MAX=300 +# Auth-specific limiter (login/register/OAuth) — separate, stricter budget from the general +# limiter above. Defaults to 20/15min if unset; only override for local load-testing. +# AUTH_RATE_LIMIT_MAX=20 +# Public kiosk lookup limiter (/students/identify) — separate, stricter budget from the general +# limiter above. Defaults to 10/15min if unset; only override for local load-testing. +# IDENTIFY_RATE_LIMIT_MAX=10 + +# --- Redis (optional) --- +# Enables Redis-backed rate limiting and cross-instance WebSocket chat broadcasting — see +# docs/DEPLOYMENT.md §8 and docs/OPERATIONS.md. Unset by default: every Redis-backed feature +# falls back to correct single-instance-only behavior with no Redis running at all. Only needed +# once running more than one instance behind a load balancer. +# REDIS_URL=redis://localhost:6379 # --- Groq (AI resume/interview features) --- # Optional: routes that need it degrade gracefully when this is unset. diff --git a/node-api/.node-version b/node-api/.node-version new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/node-api/.node-version @@ -0,0 +1 @@ +24 diff --git a/node-api/.nvmrc b/node-api/.nvmrc new file mode 100644 index 0000000..a45fd52 --- /dev/null +++ b/node-api/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/node-api/Dockerfile b/node-api/Dockerfile index d75adeb..bf33688 100644 --- a/node-api/Dockerfile +++ b/node-api/Dockerfile @@ -4,22 +4,46 @@ # ---- deps: install production dependencies in an isolated stage ---- # bcrypt needs a native build toolchain on some platforms/registries; keeping # it in its own stage means python3/make/g++ never reach the final image. -FROM node:22-alpine AS deps +FROM node:24-alpine AS deps WORKDIR /app RUN apk add --no-cache python3 make g++ COPY package.json package-lock.json* ./ RUN npm ci --omit=dev # ---- runtime: slim final image — only installed deps + app source ---- -FROM node:22-alpine AS runtime +FROM node:24-alpine AS runtime WORKDIR /app ENV NODE_ENV=production +# The base image bundles a global npm CLI (+ corepack) for convenience, but +# this container only ever runs `node src/server.js` — npm never executes +# here. That bundled npm vendors its own internal deps (tar, undici, +# brace-expansion, ...) pinned to whatever shipped with this Node build, +# which lag behind upstream security fixes independently of anything in +# this project's package.json/package-lock.json — Trivy flags them as +# CRITICAL/HIGH even though they're unreachable dead weight. Removing the +# global npm/corepack install (not the `node` binary) drops that surface +# entirely; the db:seed-*/loadtest scripts in package.json are one-line +# `node scripts/x.js` wrappers anyway, so `docker exec ... node scripts/x.js` +# still works identically without npm present. +RUN rm -rf /usr/local/lib/node_modules/npm /usr/local/lib/node_modules/corepack \ + /usr/local/bin/npm /usr/local/bin/npx /usr/local/bin/corepack + COPY --from=deps /app/node_modules ./node_modules COPY package.json ./ COPY src ./src COPY scripts ./scripts +# middlewares/upload.js creates uploads/profile itself at module load time +# (mkdirSync, not inside a request handler) — but as the non-root `node` +# user below, it has no permission to create a new directory under /app +# (owned by root from the COPY steps above). Create it here, as root, with +# the right ownership, before dropping privileges. Without this the process +# crashes on startup (routes/index.js requires every route file, including +# profile.routes.js, before the server ever binds a port) — verified by +# actually building and running this image; it never got past `docker run`. +RUN mkdir -p uploads/profile && chown -R node:node uploads + EXPOSE 5000 USER node diff --git a/node-api/README.md b/node-api/README.md index cc213c9..c59b566 100644 --- a/node-api/README.md +++ b/node-api/README.md @@ -67,5 +67,7 @@ full per-endpoint role matrix. - [`docs/API.md`](docs/API.md) — endpoint reference - [`docs/FRONTEND_INTEGRATION.md`](docs/FRONTEND_INTEGRATION.md) — Axios client + CORS setup for the frontend -- [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) — Cloud Run + MongoDB Atlas deployment, scaling notes +- [`docs/DEPLOYMENT.md`](docs/DEPLOYMENT.md) — Cloud Run + MongoDB Atlas deployment, scaling, Redis setup +- [`docs/OPERATIONS.md`](docs/OPERATIONS.md) — WebSocket auth migration, rate-limiting architecture, monitoring, troubleshooting, rollback +- [`docs/CREDENTIAL_ROTATION.md`](docs/CREDENTIAL_ROTATION.md) — credential rotation checklist - [`scripts/setupIndexes.js`](scripts/setupIndexes.js) — full index plan (16 collections, uniqueness + query indexes) diff --git a/node-api/docs/API.md b/node-api/docs/API.md index 7dfd5f7..431c67c 100644 --- a/node-api/docs/API.md +++ b/node-api/docs/API.md @@ -11,15 +11,21 @@ Authorization: Bearer Every response follows the shape: ```json -{ "success": true, "message": "...", "data": { }, "meta": { "page": 1, "limit": 20, "total": 42 } } +{ "success": true, "message": "...", "data": { }, "timestamp": "2026-08-01T...", "meta": { "page": 1, "limit": 20, "total": 42, "totalPages": 3, "hasNext": true, "hasPrevious": false } } ``` Errors: ```json -{ "success": false, "message": "...", "details": ["optional validation messages"] } +{ "success": false, "message": "...", "detail": "...", "code": "VALIDATION_ERROR", "timestamp": "2026-08-01T...", "details": ["optional per-field validation messages"] } ``` +`code` is a stable, machine-readable identifier (`UNAUTHORIZED`, `FORBIDDEN`, `NOT_FOUND`, +`VALIDATION_ERROR`, `CONFLICT`, `TOO_MANY_REQUESTS`, `FILE_TOO_LARGE`, `SERVICE_UNAVAILABLE`, +`INTERNAL_ERROR`, ...) meant for client-side branching logic — `message`/`detail` are for display +only and may reword over time. `detail` duplicates `message` for legacy clients that read +FastAPI's `HTTPException` shape; new code should read `message` or `code`. + ## Roles `super_admin` · `institution_admin` · `hr` · `faculty` · `student` @@ -37,6 +43,12 @@ service-layer checks. Students only ever see/act on their own student/applicatio | POST | `/auth/google` | none | `idToken` | Verifies Google ID token server-side | | POST | `/auth/refresh` | none | `refreshToken` | Returns new `{ accessToken, refreshToken }` | | GET | `/auth/me` | any | — | Current user profile | +| PUT | `/auth/change-password` | any | `currentPassword`/`current_password`, `newPassword`/`new_password` | Bumps `token_version` (invalidates other sessions' refresh tokens), returns a fresh token pair for the caller's own session | + +`POST /auth/refresh` accepts the token as either `refreshToken` or `refresh_token` in the body, and +its response includes the new tokens both nested under `data` (standard envelope) *and* flattened +at the top level (`access_token`/`refresh_token`) — a compatibility shim for a bare-axios frontend +call site that skips the usual envelope-unwrap interceptor. New clients can use either shape. Non-student accounts (institution_admin, hr, faculty) are provisioned by `super_admin` / `institution_admin` through `POST /users`, not through public registration. @@ -89,6 +101,10 @@ Placement drives posted by a company at an institution. | PUT | `/:id` | super_admin, institution_admin, hr | | | DELETE | `/:id` | super_admin, institution_admin | | +Also mounted at `/jobs` — same router, same controller/service, identical behavior under both +prefixes (including `/jobs/me`, `/jobs/drives`, `/jobs/applications/me`). Exists for a frontend +module that still calls the pre-migration `/jobs` path name. + ## Placement Applications — `/placement-applications` | Method | Path | Roles | Notes | @@ -137,4 +153,24 @@ Read-only audit trail. `super_admin` only. Entries are written internally ## Pagination -All list endpoints accept `?page=1&limit=20` (limit capped at 100) and return `meta.total`/`meta.page`/`meta.limit`. +All list endpoints accept `?page=1&limit=20` (limit capped at 100) and `?sortBy=&sortOrder=asc|desc` +(`sortBy` is whitelisted against that entity's real columns server-side — an unrecognized field is +a silent no-op, not a 400). Responses include `meta.total`/`meta.page`/`meta.limit`/`meta.totalPages`/ +`meta.hasNext`/`meta.hasPrevious`. + +A few endpoints intentionally return everything unpaginated rather than truncating silently — +`GET /departments` (a college realistically has dozens, not thousands, and every consumer wants +the full list for a dropdown) being the clearest example. Where an endpoint returns more than +`limit` rows without real pagination (e.g. `GET /jobs/me`, capped at 1000), the true total is +still exposed via an `X-Total-Count` response header even though the body stays a plain array. + +## Health, readiness, and metrics — `/health` + +Unauthenticated, and — unlike every endpoint above — not subject to rate limiting (see +`docs/OPERATIONS.md`'s "Rate limiting architecture" for why). + +| Method | Path | Notes | +|--------|------|-------| +| GET | `/health`, `/health/live` | Liveness — process is alive, no downstream checks | +| GET | `/health/ready` | Readiness — real Mongo ping (required), Redis ping if configured (informational only) | +| GET | `/health/metrics` | Plain JSON: uptime, memory, local WebSocket connection count, Redis state | diff --git a/node-api/docs/CREDENTIAL_ROTATION.md b/node-api/docs/CREDENTIAL_ROTATION.md new file mode 100644 index 0000000..5f63bed --- /dev/null +++ b/node-api/docs/CREDENTIAL_ROTATION.md @@ -0,0 +1,111 @@ +# Credential Rotation Runbook + +## Why this exists + +A MongoDB Atlas password previously lived in plaintext in `python-service/seed_mongo.py`. +`python-service/` has since been deleted (see `SECURITY_AUDIT.md` §13), but deleting the file does +**not** rotate the credential — it is still recoverable from git history by anyone with read access +to this repository. It must be treated as compromised and rotated, independent of the file's removal. + +This document is the general-purpose process for rotating any credential this service depends on +(`MONGODB_URI`, `JWT_SECRET`, `JWT_REFRESH_SECRET`, `GOOGLE_CLIENT_SECRET`, `BREVO_API_KEY`, +`GROQ_API_KEY`, `REDIS_URL`), not just the one that's already known-compromised. + +## What actually has to happen, and who does it + +Rotating the Atlas password requires access to the MongoDB Atlas console/API for this project — +**this step cannot be performed by an automated coding assistant**; it has to be done by whoever +holds Atlas access for this org. Everything else on this checklist (updating secrets stores, +verifying the app reconnects, confirming the old credential is dead) can be done once the new +password exists. + +## Checklist + +### 1. Generate the new credential + +- [ ] Atlas → Database Access → the affected database user → Edit → Generate a new, strong + auto-generated password (don't hand-type one — Atlas's generator avoids characters that need + extra URL-encoding in a `mongodb+srv://` connection string). +- [ ] Copy the new full connection string immediately — Atlas will not show the password again. +- [ ] Record the rotation date and who performed it (bottom of this file, or your team's incident log). + +### 2. Update every environment that holds the old credential + +Check off only after confirming the *new* value is actually live in that environment — updating a +secret store without redeploying/restarting the consumer leaves it running on the old value. + +- [ ] **Local development** (`node-api/.env.node`) — update `MONGODB_URI`, confirm + `npm run dev` connects (watch for `"Connected to MongoDB"` in the startup log). +- [ ] **CI** (GitHub Actions secrets, if any workflow uses a real `MONGODB_URI` rather than the + ephemeral `mongo:7` service container `node-test`/`docker-build` already use) — update the + repo/org secret. +- [ ] **Staging** — update the secret store (GCP Secret Manager / equivalent), redeploy or restart + the service so it picks up the new value, confirm `/health/ready` reports `mongo.status: ok`. +- [ ] **Production** — same as staging. Deploy the new secret *before* invalidating the old + credential (next step) so there's no window where neither works. +- [ ] **Docker/local containers** — any `docker run`/`docker-compose` invocation that passes + `MONGODB_URI` via `-e` or an env file. +- [ ] Any other service/script that reads this credential directly (seed scripts, one-off admin + tooling) — grep for `MONGODB_URI` outside of `node-api/` to be sure nothing was missed. + +### 3. Invalidate the old credential + +Only after every environment above is confirmed running on the new value: + +- [ ] Atlas → Database Access → delete the old database user (or, if it's the same user with a + rotated password, this step is already done by step 1 — Atlas doesn't keep the old password + valid after a reset). Confirm: attempting to connect with the old connection string now fails. + +### 4. Verify + +- [ ] Application starts cleanly in every updated environment (`"Connected to MongoDB"` log line, + no startup crash). +- [ ] `GET /health/ready` returns `{"status":"ready", "checks":{"mongo":{"status":"ok",...}}}` in + each environment. +- [ ] `npm run db:setup-indexes` (or equivalent) still runs against the new connection string if + you need to re-verify indexes exist. +- [ ] A real login (`POST /auth/login`) succeeds end-to-end — proves the app can both read (find + user) and write (update `last_login_at`) with the new credential, not just connect. +- [ ] No service anywhere is still configured with the old connection string (search every + environment's secret store / env file for the old value, not just the ones you remember + updating). + +### 5. Record the rotation + +| Field | Value | +|---|---| +| Credential rotated | `MONGODB_URI` (Atlas database user password) | +| Rotation date | _fill in when performed_ | +| Performed by | _fill in_ | +| Old credential invalidated | _yes/no + date_ | +| Environments updated | local / CI / staging / production / other: _____ | +| Verification method | `/health/ready` + real login, per §4 above | + +## Rollback + +If the new credential turns out to be wrong (typo, wrong user, insufficient permissions) and the +old one hasn't been invalidated yet: just revert the secret store / env value back to the old +connection string and redeploy — the old credential is still valid until step 3 is done, which is +exactly why step 3 is ordered *after* step 2's verification, not before it. + +If the old credential was already invalidated and the new one is broken: generate another new +password (back to step 1) — Atlas doesn't restore a deleted/rotated-away credential. + +## Applying this same process to other credentials + +The same five-step shape (generate → update every environment → invalidate old → verify → record) +applies to: + +- **`JWT_SECRET` / `JWT_REFRESH_SECRET`** — rotating these invalidates *every* currently-issued + access and refresh token instantly (unlike a per-user `token_version` bump — see + `auth.service.js` — this is global). Every logged-in user gets signed out. Plan this as a + maintenance-window action, not a silent rotation. +- **`GOOGLE_CLIENT_SECRET`** — rotate in Google Cloud Console → APIs & Services → Credentials; + "Continue with Google" stops working for any environment still on the old secret until updated. +- **`REDIS_URL`** (if using an authenticated Redis) — rate limiting and chat broadcasting both + degrade to their single-instance fallback automatically if Redis becomes unreachable mid-rotation + (see `config/redis.js`), so this one is lower-risk to rotate than the others — nothing goes down, + it just temporarily loses cross-instance coordination. +- **`BREVO_API_KEY` / `GROQ_API_KEY`** — lowest risk: `email.service.js`/`groqClient.js` both + already check `isEmailConfigured()`/`isGroqConfigured()` and degrade gracefully rather than crash + if these are absent or invalid — email sending / AI features pause, nothing else breaks. diff --git a/node-api/docs/DEPLOYMENT.md b/node-api/docs/DEPLOYMENT.md index 52a877c..be1abc7 100644 --- a/node-api/docs/DEPLOYMENT.md +++ b/node-api/docs/DEPLOYMENT.md @@ -76,9 +76,55 @@ Notes: - **Read scaling**: for read-heavy load (dashboards, leaderboards), use an Atlas read replica / secondary with `readPreference: 'secondaryPreferred'` for read-only repository queries; writes stay on the primary. -- **Rate limiting**: `express-rate-limit` protects a single instance; behind Cloud Run's autoscaling - this is per-instance, not global — for a hard global cap, move rate limiting to Cloud Armor or an - API Gateway in front of Cloud Run. +- **Rate limiting**: Redis-backed when `REDIS_URL` is set (shared counters across every instance — + see §8 below); falls back to per-instance in-memory counting when it isn't, or during a Redis + outage. Below a handful of instances the in-memory fallback is usually fine; once traffic is + spread across many replicas, set `REDIS_URL` so the limit is enforced globally rather than + `max * instance_count`. +- **WebSocket broadcasting**: same story — Redis pub/sub (`REDIS_URL` set) is required for a chat + message to reach a user connected to a *different* instance than the sender. Single-instance + deployments don't need this; anything with `--min-instances` > 1 does. + +## 8. Redis (rate limiting + WebSocket broadcasting across instances) + +Optional at low scale, required once `--max-instances` (or equivalent) is set above 1 and you +want rate limits and chat delivery to work correctly *across* instances rather than per-instance. + +```bash +# Memorystore for Redis (GCP) — or any Redis 6+, including a self-hosted one behind VPC peering. +gcloud redis instances create upscaler-ai-redis \ + --size=1 --region=asia-south1 --redis-version=redis_7_0 + +# Cloud Run must be on the same VPC (via a Serverless VPC Access connector) to reach it — +# Memorystore has no public IP by design. +gcloud run deploy upscaler-ai-api \ + --vpc-connector YOUR_CONNECTOR \ + --set-env-vars REDIS_URL=redis://10.x.x.x:6379 \ + ...(other flags from §4) +``` + +Nothing else changes — `config/redis.js` picks up `REDIS_URL` automatically, and every +Redis-backed feature (rate limiting, chat broadcast) degrades to its single-instance behavior +if Redis is unreachable rather than failing requests. Verify after deploying: + +```bash +curl https://your-service-url/health/ready +# {"status":"ready","checks":{"mongo":{"status":"ok",...},"redis":{"status":"ok","latencyMs":...}}} +``` + +## 9. Health, readiness, and metrics endpoints + +All three are registered *before* the rate limiter (deliberately — see app.js) so load balancer / +orchestrator probes are never subject to the same request budget as real API traffic: + +| Endpoint | Purpose | Checks | +|---|---|---| +| `GET /health`, `GET /health/live` | Liveness — is the process alive | Nothing downstream (never fails due to Mongo/Redis being down — that would cause a restart loop) | +| `GET /health/ready` | Readiness — can this instance serve traffic | Real Mongo ping (required); real Redis ping if `REDIS_URL` is set (informational only — never fails readiness) | +| `GET /health/metrics` | Plain JSON operational snapshot | Uptime, memory, local WebSocket connection count, Redis configured/ready state | + +Point Cloud Run's liveness/startup probes (or Kubernetes' `livenessProbe`/`readinessProbe`) at +`/health/live` and `/health/ready` respectively. ## 6. Frontend deployment diff --git a/node-api/docs/OPERATIONS.md b/node-api/docs/OPERATIONS.md new file mode 100644 index 0000000..cfcd736 --- /dev/null +++ b/node-api/docs/OPERATIONS.md @@ -0,0 +1,110 @@ +# Operations Guide + +Covers what isn't already in `DEPLOYMENT.md` (provisioning/deploy steps) or `CREDENTIAL_ROTATION.md` +(credential rotation): the WebSocket auth migration, rate-limiting architecture, monitoring, +troubleshooting, and rollback. + +## WebSocket authentication migration + +The chat WebSocket (`GET /api/v1/chat/ws`) authenticates via a token passed as a **WS subprotocol** +(`new WebSocket(url, [token])`, arrives server-side as the `Sec-WebSocket-Protocol` header) rather +than a `?token=` query string. Query strings end up in server access logs, browser history, and any +`Referer` header the page sends afterward — subprotocols don't. + +The old query-string path is still accepted as a fallback (`chatServer.js#authenticate` checks the +subprotocol first, falls back to `?token=` only if absent) and every use of the fallback is logged: + +```json +{"level":"warn","message":"WebSocket: legacy ?token= query-string auth used","userAgent":"...","origin":"..."} +``` + +**Migration phases** (this repo is currently at the end of Phase 1 — the frontend switch and +server-side logging are both done; Phases 2–3 are future work, not yet scheduled): + +1. **Phase 1 (done)** — server accepts both, prefers subprotocol, logs every fallback use. Frontend + (`useReconnectingSocket.ts`) already sends the subprotocol exclusively. +2. **Phase 2 (not started)** — once Phase 1's logs show the fallback is unused in production for a + full deploy cycle (confirms no external client, mobile app, or forgotten cached frontend bundle + still depends on `?token=`), start actively surfacing a deprecation warning to any client that + still hits it (e.g. include a `Deprecation` response header on the handshake, or a + `{type:"deprecation_warning"}` app-level message right after connecting). +3. **Phase 3 (not started)** — remove the `?token=` parsing branch from `authenticate()` entirely. + Update this doc and `chatServer.js`'s comments accordingly. + +Do not skip straight to Phase 3 without confirming Phase 1's logs are actually clean — that log +line is the only signal that anything still depends on the old path. + +## Rate limiting architecture + +`middlewares/rateLimiter.js` exports the same three limiters (`apiLimiter`, `authLimiter`, +`identifyLimiter`) as before — call sites (`route.use(apiLimiter)` etc.) never changed. What backs +them did: `middlewares/rateLimitStore.js`'s `HybridRateLimitStore` uses Redis +(`config/redis.js#getRedisClient`) when `REDIS_URL` is set and currently reachable, and an +in-process `Map` otherwise — checked on every single increment, not cached, so it self-heals the +moment Redis reconnects without any separate health-polling loop. + +- **Single instance, no `REDIS_URL`**: in-memory counting, same behavior as before this work. +- **Multiple instances, `REDIS_URL` set**: limits are enforced globally (a client hitting instance A + then instance B still shares one counter). +- **Multiple instances, Redis temporarily down**: each instance falls back to counting + independently for the duration of the outage — looser than the intended global limit (a client + could get `max * instance_count` requests through instead of `max`), but never zero protection, + and never a failed/hung request waiting on a wedged Redis (`maxRetriesPerRequest: 1` in + `config/redis.js` bounds that). + +Health/readiness/metrics endpoints (`GET /health`, `/health/live`, `/health/ready`, +`/health/metrics`) are registered **before** `apiLimiter` in `app.js` specifically so load +balancer/orchestrator probes never compete with real traffic for the same budget — verified by +load-testing `/health` directly; it returned HTTP 429 under sustained load before this ordering fix. + +## Monitoring guide + +- **Correlation IDs**: every request gets an `X-Request-Id` (inbound value honored if the caller — + e.g. a load balancer — already set one; generated otherwise) via `middlewares/requestId.js`, + echoed back as a response header and included in every structured log line for that request. + Use it to trace one request across logs when investigating an issue a user reports. +- **Structured request logs**: `middlewares/requestLogger.js` emits one JSON line per completed + request (`{requestId, method, path, statusCode, durationMs, userId, role}`) — distinct from + morgan's human-readable access log (still present, for local dev console reading). Feed this into + whatever log aggregator you have for latency percentiles / error-rate dashboards; morgan's text + format isn't reliably parseable for that. +- **`GET /health/metrics`**: uptime, memory (RSS/heap), local WebSocket connection count, Redis + configured/ready state. Plain JSON, not a Prometheus text-format exporter — no `prom-client` + dependency was added. WebSocket counts are *local to whichever instance answers the request*, not + a cluster-wide total (see DEPLOYMENT.md §8) — aggregate across instances at the infra layer if + the cluster-wide number matters. +- **What to alert on**: `/health/ready` returning 503 (Mongo unreachable — this is the one that + should page someone), `/health/metrics`'s `redis.configured: true, redis.ready: false` persisting + for more than a few minutes (degraded mode, not down, but worth knowing about), and a sustained + rise in `statusCode >= 500` in the structured request logs. + +## Troubleshooting + +| Symptom | Likely cause | Check | +|---|---|---| +| Container exits immediately on startup | Missing `JWT_SECRET`/`JWT_REFRESH_SECRET`/`MONGODB_URI` — `config/env.js` throws synchronously if any are unset | Startup log's last line before exit | +| Container starts then crashes on first real request | Filesystem permission issue (e.g. the `uploads/profile` directory ownership bug fixed in this pass — see `Dockerfile`) | `docker logs ` for an `EACCES`/`ENOENT` stack trace | +| `/health` returns 200 but `/health/ready` returns 503 | Mongo unreachable — network/firewall/Atlas maintenance/wrong `MONGODB_URI` | `checks.mongo.status` in the `/health/ready` response (the actual driver error is logged server-side, not returned in the response — see the security note below) | +| Rate limiting seems inconsistent across requests to "the same" limit | Multiple instances without `REDIS_URL` set — each is counting independently | `/health/metrics`'s `redis.configured` | +| A user reports "logged out unexpectedly" right after an admin action | Expected if they changed their password or an admin reset it — both bump `token_version`, invalidating outstanding refresh tokens (see `auth.service.js`) | Not a bug; confirm via `recordActivity` log for that user | +| WebSocket messages not arriving between two users | If multi-instance: confirm `REDIS_URL` is set on **every** instance, not just some | `/health/metrics`'s `redis.ready` on each instance; server logs for "Chat broadcaster: Redis-backed" vs "in-memory" at startup | + +**Security note on `/health/ready`**: it deliberately never echoes a raw driver error message in +its response (both Mongo and Redis failures are logged server-side with `logger.error`/`warn` and +reported to the client as just `{"status":"error"}`) — this endpoint is unauthenticated by design +(load balancer probe), so it follows the same discipline as `errorHandler.js`: never leak internal +error detail to an unauthenticated caller. + +## Disaster recovery / rollback + +- **Bad deploy**: Cloud Run (or equivalent) keeps prior revisions — route traffic back to the last + known-good revision. This service is fully stateless (JWT auth, no server-side session store), so + rolling back is safe at any time with no session-migration concern. +- **Bad Redis rotation/outage**: no rollback needed — every Redis-backed feature already falls back + to single-instance behavior automatically (see "Rate limiting architecture" above and + `websocket/broadcaster.js`). Fix Redis when convenient; nothing is on fire in the meantime. +- **Bad credential rotation**: see `CREDENTIAL_ROTATION.md`'s own rollback section. +- **Database issue**: MongoDB Atlas handles its own replication/backups (point-in-time restore via + Atlas's own tooling) — this app has no bespoke backup/restore scripts of its own to run. +- **General principle**: because there's no server-side session state, "roll back the deployment" + and "roll back the database" are independent operations — you never need to coordinate the two. diff --git a/node-api/eslint.config.js b/node-api/eslint.config.js index b8f6ebf..e98c503 100644 --- a/node-api/eslint.config.js +++ b/node-api/eslint.config.js @@ -24,10 +24,18 @@ module.exports = [ clearInterval: 'readonly', URL: 'readonly', URLSearchParams: 'readonly', + fetch: 'readonly', + AbortController: 'readonly', }, }, rules: { - 'no-unused-vars': ['warn', { args: 'none' }], + // ignoreRestSiblings: true — this codebase's standard way to omit + // fields from an entity before returning it is destructuring them out + // (`const { password_hash, ...safe } = user; return safe;`, see + // auth.service.js#sanitizeUser and friends). `password_hash` etc. are + // deliberately unused bindings, not dead code — this option is exactly + // for that pattern, not a suppression of real issues. + 'no-unused-vars': ['error', { args: 'none', ignoreRestSiblings: true }], }, }, { @@ -42,6 +50,7 @@ module.exports = [ afterAll: 'readonly', beforeEach: 'readonly', afterEach: 'readonly', + jest: 'readonly', }, }, }, diff --git a/node-api/jest.config.js b/node-api/jest.config.js new file mode 100644 index 0000000..f05e1e6 --- /dev/null +++ b/node-api/jest.config.js @@ -0,0 +1,14 @@ +module.exports = { + // Jest's default testMatch treats every .js file under __tests__/ as a + // test file, which picks up __tests__/helpers/*.js (shared test utilities, + // no tests of their own) and fails the run ("must contain at least one + // test"). Scope it to *.test.js explicitly instead. + testMatch: ['**/*.test.js'], + // Each integration test file boots its own mongodb-memory-server instance + // (a real mongod process — see __tests__/helpers/testApp.js). Running many + // of those at once contends for CPU/IO badly enough to blow past Jest's + // default 5s per-hook timeout; capping worker concurrency and raising the + // timeout keeps the suite reliable both locally and in CI. + maxWorkers: 2, + testTimeout: 30000, +}; diff --git a/node-api/package-lock.json b/node-api/package-lock.json index be48bee..1d11e37 100644 --- a/node-api/package-lock.json +++ b/node-api/package-lock.json @@ -13,11 +13,11 @@ "compression": "^1.7.4", "cors": "^2.8.5", "dotenv": "^16.4.5", - "express": "^4.19.2", + "express": "^5.2.1", "express-rate-limit": "^7.2.0", "google-auth-library": "^10.9.1", - "groq-sdk": "^0.9.1", "helmet": "^7.1.0", + "ioredis": "^6.0.0", "joi": "^17.13.0", "jsonwebtoken": "^9.0.2", "mongodb": "^6.10.0", @@ -35,7 +35,7 @@ "supertest": "^7.2.2" }, "engines": { - "node": ">=18.0.0" + "node": ">=24.0.0" } }, "node_modules/@babel/code-frame": { @@ -900,6 +900,12 @@ "url": "https://github.com/sponsors/nzakas" } }, + "node_modules/@ioredis/commands": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-2.0.0.tgz", + "integrity": "sha512-vrx0AE/T0h7cRZwfo1M39Cr+ZhZrkf0V8mQN75wucKCxCLD9l/VX6no3gFvrLqD1IlG/1LtzWovqEw3t0Vr9zg==", + "license": "MIT" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1727,21 +1733,12 @@ "version": "18.19.130", "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.130.tgz", "integrity": "sha512-GRaXQx6jGfL8sKfaIDD6OupbIHBr9jv7Jnaml9tB7l4v068PAOXqfcujMMo5PhbIs6ggR1XODELqahT2R8v0fg==", + "dev": true, "license": "MIT", "dependencies": { "undici-types": "~5.26.4" } }, - "node_modules/@types/node-fetch": { - "version": "2.6.13", - "resolved": "https://registry.npmjs.org/@types/node-fetch/-/node-fetch-2.6.13.tgz", - "integrity": "sha512-QGpRVpzSaUs30JBSGPjOg4Uveu384erbHBoT1zeONvyCfwQxIkUshLAOqN/k9EjGviPRmWTTe6aH2qySWKTVSw==", - "license": "MIT", - "dependencies": { - "@types/node": "*", - "form-data": "^4.0.4" - } - }, "node_modules/@types/stack-utils": { "version": "2.0.3", "resolved": "https://registry.npmjs.org/@types/stack-utils/-/stack-utils-2.0.3.tgz", @@ -2137,35 +2134,39 @@ "win32" ] }, - "node_modules/abort-controller": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/abort-controller/-/abort-controller-3.0.0.tgz", - "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", + "node_modules/accepts": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/accepts/-/accepts-2.0.0.tgz", + "integrity": "sha512-5cvg6CtKwfgdmVqY1WIiXKc3Q1bkRqGLi+2W/6ao+6Y7gu/RCwRuAhGEzh5B4KlszSuTLgZYuqFqo5bImjNKng==", "license": "MIT", "dependencies": { - "event-target-shim": "^5.0.0" + "mime-types": "^3.0.0", + "negotiator": "^1.0.0" }, "engines": { - "node": ">=6.5" + "node": ">= 0.6" } }, - "node_modules/accepts": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", - "integrity": "sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==", + "node_modules/accepts/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", "license": "MIT", "dependencies": { - "mime-types": "~2.1.34", - "negotiator": "0.6.3" + "mime-db": "^1.54.0" }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/accepts/node_modules/negotiator": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.3.tgz", - "integrity": "sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -2203,18 +2204,6 @@ "node": ">= 14" } }, - "node_modules/agentkeepalive": { - "version": "4.6.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.6.0.tgz", - "integrity": "sha512-kja8j7PjmncONqaTsB8fQ+wE2mSU2DJ9D4XKoJ5PFWIdRMa6SLSN1ff4mOr4jCbfRSsxR4keIiySJU0N9T5hIQ==", - "license": "MIT", - "dependencies": { - "humanize-ms": "^1.2.1" - }, - "engines": { - "node": ">= 8.0.0" - } - }, "node_modules/ajv": { "version": "6.15.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", @@ -2324,12 +2313,6 @@ "sprintf-js": "~1.0.2" } }, - "node_modules/array-flatten": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/array-flatten/-/array-flatten-1.1.1.tgz", - "integrity": "sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==", - "license": "MIT" - }, "node_modules/asap": { "version": "2.0.6", "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", @@ -2357,6 +2340,7 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "dev": true, "license": "MIT" }, "node_modules/b4a": { @@ -2656,27 +2640,110 @@ } }, "node_modules/body-parser": { - "version": "1.20.6", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.6.tgz", - "integrity": "sha512-p5tAzS57i5MV9fZFDj9LeIiTZEufbSe2eDozP+ElheSUq1m74CRq1jI4mYNDdVs9vQztXFLuk/Gd6BWTdwRJ5g==", + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-2.3.0.tgz", + "integrity": "sha512-2cGmJupaNgg+QUwVLAucDuWuoMZ6EX9iHDRswZ5lsNYEmwPaRknMPCLZz07yTzVq/83p4o/wzbDZbBrTvGGTIw==", "license": "MIT", "dependencies": { - "bytes": "~3.1.2", - "content-type": "~1.0.5", - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "~1.2.0", - "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", - "on-finished": "~2.4.1", - "qs": "~6.15.1", - "raw-body": "~2.5.3", - "type-is": "~1.6.18", - "unpipe": "~1.0.0" + "bytes": "^3.1.2", + "content-type": "^2.0.0", + "debug": "^4.4.3", + "http-errors": "^2.0.1", + "iconv-lite": "^0.7.2", + "on-finished": "^2.4.1", + "qs": "^6.15.2", + "raw-body": "^3.0.2", + "type-is": "^2.1.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/body-parser/node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/body-parser/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/body-parser/node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" }, "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/brace-expansion": { @@ -2973,6 +3040,15 @@ "node": ">=12" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.1.tgz", + "integrity": "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/co": { "version": "4.6.0", "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz", @@ -3041,6 +3117,7 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -3119,15 +3196,16 @@ } }, "node_modules/content-disposition": { - "version": "0.5.4", - "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", - "integrity": "sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==", + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-1.1.0.tgz", + "integrity": "sha512-5jRCH9Z/+DRP7rkvY83B+yGIGX96OYdJmzngqnw2SBSxqCFPd0w2km3s5iawpGX8krnwSGmF0FW5Nhr0Hfai3g==", "license": "MIT", - "dependencies": { - "safe-buffer": "5.2.1" - }, "engines": { - "node": ">= 0.6" + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/content-type": { @@ -3156,10 +3234,13 @@ } }, "node_modules/cookie-signature": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.0.7.tgz", - "integrity": "sha512-NXdYc3dLr47pBkpUCHtKSwIOQXLVn8dZEuywboCOJY/osA0wFSLlSawr3KN8qXJEyX66FcONTH8EIlVuK0yyFA==", - "license": "MIT" + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", + "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", + "license": "MIT", + "engines": { + "node": ">=6.6.0" + } }, "node_modules/cookiejar": { "version": "2.1.4", @@ -3254,11 +3335,21 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" } }, + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "license": "Apache-2.0", + "engines": { + "node": ">=0.10" + } + }, "node_modules/depd": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/depd/-/depd-2.0.0.tgz", @@ -3268,16 +3359,6 @@ "node": ">= 0.8" } }, - "node_modules/destroy": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/destroy/-/destroy-1.2.0.tgz", - "integrity": "sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==", - "license": "MIT", - "engines": { - "node": ">= 0.8", - "npm": "1.2.8000 || >= 1.4.16" - } - }, "node_modules/detect-newline": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/detect-newline/-/detect-newline-3.1.0.tgz", @@ -3433,6 +3514,7 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -3748,15 +3830,6 @@ "node": ">= 0.6" } }, - "node_modules/event-target-shim": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/event-target-shim/-/event-target-shim-5.0.1.tgz", - "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "license": "MIT", - "engines": { - "node": ">=6" - } - }, "node_modules/events-universal": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/events-universal/-/events-universal-1.0.1.tgz", @@ -3820,45 +3893,42 @@ } }, "node_modules/express": { - "version": "4.22.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.22.2.tgz", - "integrity": "sha512-IuL+Elrou2ZvCFHs18/CIzy2Nzvo25nZ1/D2eIZlz7c+QUayAcYoiM2BthCjs+EBHVpjYjcuLDAiCWgeIX3X1Q==", - "license": "MIT", - "dependencies": { - "accepts": "~1.3.8", - "array-flatten": "1.1.1", - "body-parser": "~1.20.5", - "content-disposition": "~0.5.4", - "content-type": "~1.0.4", - "cookie": "~0.7.1", - "cookie-signature": "~1.0.6", - "debug": "2.6.9", - "depd": "2.0.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "finalhandler": "~1.3.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.0", - "merge-descriptors": "1.0.3", - "methods": "~1.1.2", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "path-to-regexp": "~0.1.12", - "proxy-addr": "~2.0.7", - "qs": "~6.15.1", - "range-parser": "~1.2.1", - "safe-buffer": "5.2.1", - "send": "~0.19.0", - "serve-static": "~1.16.2", - "setprototypeof": "1.2.0", - "statuses": "~2.0.1", - "type-is": "~1.6.18", - "utils-merge": "1.0.1", - "vary": "~1.1.2" + "version": "5.2.1", + "resolved": "https://registry.npmjs.org/express/-/express-5.2.1.tgz", + "integrity": "sha512-hIS4idWWai69NezIdRt2xFVofaF4j+6INOpJlVOLDO8zXGpUVEVzIYk12UUi2JzjEzWL3IOAxcTubgz9Po0yXw==", + "license": "MIT", + "dependencies": { + "accepts": "^2.0.0", + "body-parser": "^2.2.1", + "content-disposition": "^1.0.0", + "content-type": "^1.0.5", + "cookie": "^0.7.1", + "cookie-signature": "^1.2.1", + "debug": "^4.4.0", + "depd": "^2.0.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "finalhandler": "^2.1.0", + "fresh": "^2.0.0", + "http-errors": "^2.0.0", + "merge-descriptors": "^2.0.0", + "mime-types": "^3.0.0", + "on-finished": "^2.4.1", + "once": "^1.4.0", + "parseurl": "^1.3.3", + "proxy-addr": "^2.0.7", + "qs": "^6.14.0", + "range-parser": "^1.2.1", + "router": "^2.2.0", + "send": "^1.1.0", + "serve-static": "^2.2.0", + "statuses": "^2.0.1", + "type-is": "^2.0.1", + "vary": "^1.1.2" }, "engines": { - "node": ">= 0.10.0" + "node": ">= 18" }, "funding": { "type": "opencollective", @@ -3880,6 +3950,89 @@ "express": ">= 4.11" } }, + "node_modules/express/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/express/node_modules/media-typer": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/media-typer/-/media-typer-1.1.1.tgz", + "integrity": "sha512-yz3xRaG20c6/BOzvYoDaGtPmGscs7YivItZEEqe6GbwNfHuxu9YNmvnEkMzKldAGY4/80pRcQRZSEnhquk9XuQ==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, + "node_modules/express/node_modules/type-is": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/type-is/-/type-is-2.1.0.tgz", + "integrity": "sha512-faYHw0anBbc/kWF3zFTEnxSFOAGUX9GFbOBthvDdLsIlEoWOFOtS0zgCiQYwIskL9iGXZL3kAXD8OoZ4GmMATA==", + "license": "MIT", + "dependencies": { + "content-type": "^2.0.0", + "media-typer": "^1.1.0", + "mime-types": "^3.0.0" + }, + "engines": { + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/express/node_modules/type-is/node_modules/content-type": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/content-type/-/content-type-2.0.0.tgz", + "integrity": "sha512-j/O/d7GcZCyNl7/hwZAb606rzqkyvaDctLmckbxLzHvFBzTJHuGEdodATcP3yIRoDrLHkIATJuvzbFlp/ki2cQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/extend": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz", @@ -3996,23 +4149,49 @@ } }, "node_modules/finalhandler": { - "version": "1.3.2", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.2.tgz", - "integrity": "sha512-aA4RyPcd3badbdABGDuTXCMTtOneUCAYH/gxoYRTZlIJdF0YPWuGqiAsIrhNnnqdXGswYk6dGujem4w80UJFhg==", + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-2.1.1.tgz", + "integrity": "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA==", "license": "MIT", "dependencies": { - "debug": "2.6.9", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "on-finished": "~2.4.1", - "parseurl": "~1.3.3", - "statuses": "~2.0.2", - "unpipe": "~1.0.0" + "debug": "^4.4.0", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "on-finished": "^2.4.1", + "parseurl": "^1.3.3", + "statuses": "^2.0.1" }, "engines": { - "node": ">= 0.8" + "node": ">= 18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, + "node_modules/finalhandler/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/finalhandler/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/find-cache-dir": { "version": "3.3.2", "resolved": "https://registry.npmjs.org/find-cache-dir/-/find-cache-dir-3.3.2.tgz", @@ -4127,6 +4306,7 @@ "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", + "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -4139,25 +4319,6 @@ "node": ">= 6" } }, - "node_modules/form-data-encoder": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-1.7.2.tgz", - "integrity": "sha512-qfqtYan3rxrnCk1VYaA4H+Ms9xdpPqvLZa6xmMgFvhO32x7/3J/ExcTd6qpxM0vH2GdMI+poehyBZvqfMTto8A==", - "license": "MIT" - }, - "node_modules/formdata-node": { - "version": "4.4.1", - "resolved": "https://registry.npmjs.org/formdata-node/-/formdata-node-4.4.1.tgz", - "integrity": "sha512-0iirZp3uVDjVGt9p49aTaqjk84TrglENEDuqfdlZQ1roC9CWlPk6Avf8EEnZNcAqPonwkG35x4n3ww/1THYAeQ==", - "license": "MIT", - "dependencies": { - "node-domexception": "1.0.0", - "web-streams-polyfill": "4.0.0-beta.3" - }, - "engines": { - "node": ">= 12.20" - } - }, "node_modules/formdata-polyfill": { "version": "4.0.10", "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", @@ -4198,12 +4359,12 @@ } }, "node_modules/fresh": { - "version": "0.5.2", - "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", - "integrity": "sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/fresh/-/fresh-2.0.0.tgz", + "integrity": "sha512-Rx/WycZ60HOaqLKAi6cHRKKI7zxWbJ31MhntmtwMoaTeF7XFH9hhBp8vITaMidfljRQ6eYWCKkaTK+ykVJHP2A==", "license": "MIT", "engines": { - "node": ">= 0.6" + "node": ">= 0.8" } }, "node_modules/fs.realpath": { @@ -4474,21 +4635,6 @@ "dev": true, "license": "ISC" }, - "node_modules/groq-sdk": { - "version": "0.9.1", - "resolved": "https://registry.npmjs.org/groq-sdk/-/groq-sdk-0.9.1.tgz", - "integrity": "sha512-yFZ3+I0Oe/u+4PUKDUG8q5KpP9Hgc+ujhlBaAbcc4EMJb2RMn/atqG8i+Vnk7s+2K4rJ49mviO/kY0i9LbskCA==", - "license": "Apache-2.0", - "dependencies": { - "@types/node": "^18.11.18", - "@types/node-fetch": "^2.6.4", - "abort-controller": "^3.0.0", - "agentkeepalive": "^4.2.1", - "form-data-encoder": "1.7.2", - "formdata-node": "^4.3.2", - "node-fetch": "^2.6.7" - } - }, "node_modules/has-flag": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-3.0.0.tgz", @@ -4515,6 +4661,7 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -4620,25 +4767,20 @@ "node": ">=10.17.0" } }, - "node_modules/humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "license": "MIT", - "dependencies": { - "ms": "^2.0.0" - } - }, "node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", + "version": "0.7.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.7.3.tgz", + "integrity": "sha512-IKXpvIzjnC9XTAUbVBcMfGS0EPaIXtW6v+zr+RRp+hqULEpo0owZax6wyRwPOJbWbzjYspQwusTsfVr0ifh4uQ==", "license": "MIT", "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" + "safer-buffer": ">= 2.1.2 < 3.0.0" }, "engines": { "node": ">=0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/ignore": { @@ -4706,6 +4848,50 @@ "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", "license": "ISC" }, + "node_modules/ioredis": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-6.0.0.tgz", + "integrity": "sha512-f+Dtubxfpf6KYFq7WVXJoOLn0bk4TJrMrN9SzeE+jrWrCWj7XX3fA6vkryafhADX+GMymRxgDJDOI33COkJc0w==", + "license": "MIT", + "dependencies": { + "@ioredis/commands": "2.0.0", + "cluster-key-slot": "1.1.1", + "debug": "4.4.3", + "denque": "2.1.0", + "redis-errors": "1.2.0", + "standard-as-callback": "2.1.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, + "node_modules/ioredis/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/ioredis/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -4788,6 +4974,12 @@ "node": ">=0.12.0" } }, + "node_modules/is-promise": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-4.0.0.tgz", + "integrity": "sha512-hvpoI6korhJMnej285dSg6nu1+e6uxs7zG3BYAm5byqDsgJNWwxzM6z6iZiAgQR4TJ30JmBTOwqZUw3WlyH3AQ==", + "license": "MIT" + }, "node_modules/is-stream": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/is-stream/-/is-stream-2.0.1.tgz", @@ -6066,10 +6258,13 @@ "license": "MIT" }, "node_modules/merge-descriptors": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", - "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-2.0.0.tgz", + "integrity": "sha512-Snk314V5ayFLhp3fkUREub6WtjBfPdCPY1Ln8/8munuLuiYhsABgBVWsozAG+MWMbVEvcdcpbi9R7ww22l9Q3g==", "license": "MIT", + "engines": { + "node": ">=18" + }, "funding": { "url": "https://github.com/sponsors/sindresorhus" } @@ -6085,23 +6280,12 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/methods/-/methods-1.1.2.tgz", "integrity": "sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==", + "dev": true, "license": "MIT", "engines": { "node": ">= 0.6" } }, - "node_modules/mime": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/mime/-/mime-1.6.0.tgz", - "integrity": "sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==", - "license": "MIT", - "bin": { - "mime": "cli.js" - }, - "engines": { - "node": ">=4" - } - }, "node_modules/mime-db": { "version": "1.54.0", "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.54.0.tgz", @@ -6526,48 +6710,6 @@ "node": ">=10.5.0" } }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "license": "MIT", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-fetch/node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", - "license": "MIT" - }, - "node_modules/node-fetch/node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", - "license": "BSD-2-Clause" - }, - "node_modules/node-fetch/node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "license": "MIT", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, "node_modules/node-gyp-build": { "version": "4.8.4", "resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-4.8.4.tgz", @@ -6719,7 +6861,6 @@ "version": "1.4.0", "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", - "dev": true, "license": "ISC", "dependencies": { "wrappy": "1" @@ -6913,10 +7054,14 @@ "license": "ISC" }, "node_modules/path-to-regexp": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.13.tgz", - "integrity": "sha512-A/AGNMFN3c8bOlvV9RreMdrv7jsmF9XIfDeCd87+I8RNg6s78BhJxMu69NEMHBSJFxKidViTEdruRwEk/WIKqA==", - "license": "MIT" + "version": "8.4.2", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-8.4.2.tgz", + "integrity": "sha512-qRcuIdP69NPm4qbACK+aDogI5CBDMi1jKe0ry5rSQJz8JVLsC7jV8XpiJjGRLLol3N+R5ihGYcrPLTno6pAdBA==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } }, "node_modules/pend": { "version": "1.2.0", @@ -7070,27 +7215,31 @@ } }, "node_modules/range-parser": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.2.1.tgz", - "integrity": "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==", + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/range-parser/-/range-parser-1.3.0.tgz", + "integrity": "sha512-hek2mFQpPuI4E1BBKrSto+BU3e3x4xuarsbiwr3+lf7p44juvFMV0XFWQAP3xUyqXA4RrXLIoaSUGbSt056ZMw==", "license": "MIT", "engines": { "node": ">= 0.6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/raw-body": { - "version": "2.5.3", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", - "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.2.tgz", + "integrity": "sha512-K5zQjDllxWkf7Z5xJdV0/B0WTNqx6vxG70zJE4N0kBs4LovmEYWJzQGxC9bS9RAKu3bgM40lrd5zoLJ12MQ5BA==", "license": "MIT", "dependencies": { "bytes": "~3.1.2", "http-errors": "~2.0.1", - "iconv-lite": "~0.4.24", + "iconv-lite": "~0.7.0", "unpipe": "~1.0.0" }, "engines": { - "node": ">= 0.8" + "node": ">= 0.10" } }, "node_modules/react-is-18": { @@ -7136,6 +7285,15 @@ "node": ">=8.10.0" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "license": "MIT", + "engines": { + "node": ">=4" + } + }, "node_modules/require-directory": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", @@ -7169,6 +7327,45 @@ "node": ">=8" } }, + "node_modules/router": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/router/-/router-2.2.0.tgz", + "integrity": "sha512-nLTrUKm2UyiL7rlhapu/Zl45FwNgkZGaCpZbIHajDYgwlJCOzLSk+cIPAnsEqV955GjILJnKbdQC1nVPz+gAYQ==", + "license": "MIT", + "dependencies": { + "debug": "^4.4.0", + "depd": "^2.0.0", + "is-promise": "^4.0.0", + "parseurl": "^1.3.3", + "path-to-regexp": "^8.0.0" + }, + "engines": { + "node": ">= 18" + } + }, + "node_modules/router/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/router/node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "license": "MIT" + }, "node_modules/safe-buffer": { "version": "5.2.1", "resolved": "https://registry.npmjs.org/safe-buffer/-/safe-buffer-5.2.1.tgz", @@ -7217,27 +7414,62 @@ } }, "node_modules/send": { - "version": "0.19.2", - "resolved": "https://registry.npmjs.org/send/-/send-0.19.2.tgz", - "integrity": "sha512-VMbMxbDeehAxpOtWJXlcUS5E8iXh6QmN+BkRX1GARS3wRaXEEgzCcB10gTQazO42tpNIya8xIyNx8fll1OFPrg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/send/-/send-1.2.1.tgz", + "integrity": "sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==", "license": "MIT", "dependencies": { - "debug": "2.6.9", - "depd": "2.0.0", - "destroy": "1.2.0", - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "etag": "~1.8.1", - "fresh": "~0.5.2", - "http-errors": "~2.0.1", - "mime": "1.6.0", - "ms": "2.1.3", - "on-finished": "~2.4.1", - "range-parser": "~1.2.1", - "statuses": "~2.0.2" + "debug": "^4.4.3", + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "etag": "^1.8.1", + "fresh": "^2.0.0", + "http-errors": "^2.0.1", + "mime-types": "^3.0.2", + "ms": "^2.1.3", + "on-finished": "^2.4.1", + "range-parser": "^1.2.1", + "statuses": "^2.0.2" }, "engines": { - "node": ">= 0.8.0" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/send/node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/send/node_modules/mime-types": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-3.0.2.tgz", + "integrity": "sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==", + "license": "MIT", + "dependencies": { + "mime-db": "^1.54.0" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/send/node_modules/ms": { @@ -7247,18 +7479,22 @@ "license": "MIT" }, "node_modules/serve-static": { - "version": "1.16.3", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.3.tgz", - "integrity": "sha512-x0RTqQel6g5SY7Lg6ZreMmsOzncHFU7nhnRWkKgWuMTu5NN0DR5oruckMqRvacAN9d5w6ARnRBXl9xhDCgfMeA==", + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-2.2.1.tgz", + "integrity": "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw==", "license": "MIT", "dependencies": { - "encodeurl": "~2.0.0", - "escape-html": "~1.0.3", - "parseurl": "~1.3.3", - "send": "~0.19.1" + "encodeurl": "^2.0.0", + "escape-html": "^1.0.3", + "parseurl": "^1.3.3", + "send": "^1.2.0" }, "engines": { - "node": ">= 0.8.0" + "node": ">= 18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/setprototypeof": { @@ -7451,6 +7687,12 @@ "node": ">=10" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==", + "license": "MIT" + }, "node_modules/statuses": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", @@ -7668,16 +7910,6 @@ "node": ">=14.18.0" } }, - "node_modules/supertest/node_modules/cookie-signature": { - "version": "1.2.2", - "resolved": "https://registry.npmjs.org/cookie-signature/-/cookie-signature-1.2.2.tgz", - "integrity": "sha512-D76uU73ulSXrD1UXF4KE2TMxVVwhsnCgfAyTg9k8P6KGZjlXKrOLe4dJQKI3Bxi5wjesZoFXJWElNWBjPZMbhg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.6.0" - } - }, "node_modules/supports-color": { "version": "5.5.0", "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-5.5.0.tgz", @@ -7925,6 +8157,7 @@ "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true, "license": "MIT" }, "node_modules/unpipe": { @@ -8021,15 +8254,6 @@ "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==", "license": "MIT" }, - "node_modules/utils-merge": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/utils-merge/-/utils-merge-1.0.1.tgz", - "integrity": "sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==", - "license": "MIT", - "engines": { - "node": ">= 0.4.0" - } - }, "node_modules/v8-to-istanbul": { "version": "9.3.0", "resolved": "https://registry.npmjs.org/v8-to-istanbul/-/v8-to-istanbul-9.3.0.tgz", @@ -8064,15 +8288,6 @@ "makeerror": "1.0.12" } }, - "node_modules/web-streams-polyfill": { - "version": "4.0.0-beta.3", - "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-4.0.0-beta.3.tgz", - "integrity": "sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==", - "license": "MIT", - "engines": { - "node": ">= 14" - } - }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -8198,7 +8413,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", - "dev": true, "license": "ISC" }, "node_modules/write-file-atomic": { diff --git a/node-api/package.json b/node-api/package.json index 235e6b3..61aee74 100644 --- a/node-api/package.json +++ b/node-api/package.json @@ -17,10 +17,12 @@ "db:backfill-student-profiles": "node scripts/backfillMissingStudentProfiles.js", "db:seed-practice-tests": "node scripts/seedPracticeTests.js", "db:seed-question-bank": "node scripts/seedQuestionBank.js", - "db:seed-departments": "node scripts/seedDepartments.js" + "db:seed-departments": "node scripts/seedDepartments.js", + "loadtest:token": "node scripts/mintLoadTestToken.js", + "loadtest": "node scripts/loadtest/run.js" }, "engines": { - "node": ">=20.0.0" + "node": ">=24.0.0" }, "dependencies": { "@getbrevo/brevo": "^6.0.2", @@ -28,11 +30,11 @@ "compression": "^1.7.4", "cors": "^2.8.5", "dotenv": "^16.4.5", - "express": "^4.19.2", + "express": "^5.2.1", "express-rate-limit": "^7.2.0", "google-auth-library": "^10.9.1", - "groq-sdk": "^0.9.1", "helmet": "^7.1.0", + "ioredis": "^6.0.0", "joi": "^17.13.0", "jsonwebtoken": "^9.0.2", "mongodb": "^6.10.0", diff --git a/node-api/scripts/checkUserRole.js b/node-api/scripts/checkUserRole.js new file mode 100644 index 0000000..b988f13 --- /dev/null +++ b/node-api/scripts/checkUserRole.js @@ -0,0 +1,41 @@ +// One-off diagnostic: prints a user's stored role/status by email. +// Read-only — makes no writes. Run with: node scripts/checkUserRole.js +require('dotenv').config(); +const { MongoClient } = require('mongodb'); + +const email = process.argv[2]; +if (!email) { + console.error('Usage: node scripts/checkUserRole.js '); + process.exit(1); +} + +(async () => { + const client = new MongoClient(process.env.MONGODB_URI); + await client.connect(); + const db = client.db(process.env.MONGODB_DB_NAME); + + const users = await db.collection('users').find({ email: email.toLowerCase() }).toArray(); + console.log(`Matching users for ${email}: ${users.length}`); + for (const u of users) { + console.log({ + _id: u._id, + email: u.email, + full_name: u.full_name, + role: u.role, + status: u.status, + institution_id: u.institution_id, + is_active: u.is_active, + created_at: u.created_at, + }); + if (u.role === 'student' || u.role === 'faculty') { + const collectionName = u.role === 'student' ? 'students' : 'faculty'; + const linked = await db.collection(collectionName).findOne({ user_id: u._id }); + console.log(` -> linked ${collectionName} row:`, linked || '(none)'); + } + } + + await client.close(); +})().catch((e) => { + console.error('ERROR', e.message); + process.exit(1); +}); diff --git a/node-api/scripts/loadtest/run.js b/node-api/scripts/loadtest/run.js new file mode 100644 index 0000000..757e4db --- /dev/null +++ b/node-api/scripts/loadtest/run.js @@ -0,0 +1,186 @@ +// Self-contained capacity benchmark: spins up an in-memory MongoDB +// (mongodb-memory-server — same tool the integration test suite already +// uses, no real network/Atlas involved), boots the real Express app against +// it with rate limits raised out of the way, seeds one user, mints a valid +// access token, then fires concurrent HTTP requests at a few representative +// endpoints and reports throughput/latency/status-code breakdown. +// +// Usage: node scripts/loadtest/run.js [--requests 6000] [--concurrency 150] +const http = require('http'); +const path = require('path'); + +function parseArgs() { + const args = process.argv.slice(2); + const get = (flag, def) => { + const i = args.indexOf(flag); + return i !== -1 ? Number(args[i + 1]) : def; + }; + return { + requestsPerEndpoint: get('--requests', 6000), + concurrency: get('--concurrency', 150), + }; +} + +async function setupServer() { + const { MongoMemoryServer } = require('mongodb-memory-server'); + const mongod = await MongoMemoryServer.create(); + + process.env.MONGODB_URI = mongod.getUri(); + process.env.MONGODB_DB_NAME = 'loadtest_upscaler_ai_node'; + process.env.JWT_SECRET = 'loadtest-jwt-secret'; + process.env.JWT_REFRESH_SECRET = 'loadtest-jwt-refresh-secret'; + process.env.JWT_EXPIRES_IN = '15m'; + process.env.JWT_REFRESH_EXPIRES_IN = '7d'; + process.env.FRONTEND_URL = 'http://localhost:3000'; + process.env.CORS_ORIGINS = 'http://localhost:3000'; + process.env.NODE_ENV = 'production'; + // Raised for this benchmark run only — never touches real .env.node / + // production config. Point is to measure the app+DB, not re-discover the + // rate limiter (already root-caused: see RATE_LIMIT_MAX default of 300). + process.env.RATE_LIMIT_MAX = '100000000'; + process.env.AUTH_RATE_LIMIT_MAX = '100000000'; + process.env.IDENTIFY_RATE_LIMIT_MAX = '100000000'; + + const projectRoot = path.join(__dirname, '..', '..'); + const database = require(path.join(projectRoot, 'src/config/database')); + await database.connect(); + // Per-request logging would otherwise drown the benchmark's own summary in + // tens of thousands of log lines — silenced for this run only. + require(path.join(projectRoot, 'src/utils/logger')).silent = true; + const app = require(path.join(projectRoot, 'src/app')); + const userRepository = require(path.join(projectRoot, 'src/repositories/user.repository')); + const { hashPassword } = require(path.join(projectRoot, 'src/utils/password')); + const { signAccessToken } = require(path.join(projectRoot, 'src/utils/jwt')); + + const server = http.createServer(app); + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); + const port = server.address().port; + + const password_hash = await hashPassword('Sup3rSecret!'); + const user = await userRepository.create({ + email: 'loadtest@example.com', + password_hash, + full_name: 'Load Test User', + role: 'student', + institution_id: null, + status: 'approved', + is_active: true, + token_version: 0, + }); + + const token = signAccessToken({ sub: user.id, role: user.role, institutionId: user.institution_id, tv: 0 }); + + return { mongod, database, server, port, token }; +} + +function percentile(sortedLatencies, p) { + if (sortedLatencies.length === 0) return 0; + const idx = Math.min(sortedLatencies.length - 1, Math.ceil((p / 100) * sortedLatencies.length) - 1); + return sortedLatencies[idx]; +} + +function runBenchmark({ host, port, path: reqPath, headers, total, concurrency }) { + return new Promise((resolve) => { + const agent = new http.Agent({ keepAlive: true, maxSockets: concurrency }); + const latencies = []; + const statusCounts = {}; + let sent = 0; + let completed = 0; + const start = process.hrtime.bigint(); + + function fireOne() { + if (sent >= total) return; + sent += 1; + const reqStart = process.hrtime.bigint(); + const req = http.request( + { host, port, path: reqPath, method: 'GET', headers, agent, timeout: 10000 }, + (res) => { + res.resume(); // drain body, we only care about status + timing + res.on('end', () => { + const ms = Number(process.hrtime.bigint() - reqStart) / 1e6; + latencies.push(ms); + statusCounts[res.statusCode] = (statusCounts[res.statusCode] || 0) + 1; + completed += 1; + if (sent < total) fireOne(); + else if (completed === total) finish(); + }); + } + ); + req.on('error', () => { + statusCounts.ERR = (statusCounts.ERR || 0) + 1; + completed += 1; + if (sent < total) fireOne(); + else if (completed === total) finish(); + }); + req.on('timeout', () => req.destroy()); + req.end(); + } + + function finish() { + const totalMs = Number(process.hrtime.bigint() - start) / 1e6; + latencies.sort((a, b) => a - b); + agent.destroy(); + resolve({ + totalMs, + statusCounts, + avg: latencies.reduce((a, b) => a + b, 0) / (latencies.length || 1), + p50: percentile(latencies, 50), + p95: percentile(latencies, 95), + p99: percentile(latencies, 99), + max: latencies[latencies.length - 1] || 0, + rps: (total / totalMs) * 1000, + }); + } + + for (let i = 0; i < Math.min(concurrency, total); i++) fireOne(); + }); +} + +function report(name, r, total) { + const ok2xx = Object.entries(r.statusCounts) + .filter(([code]) => code[0] === '2') + .reduce((sum, [, n]) => sum + n, 0); + console.log(`\n=== ${name} ===`); + console.log(` requests: ${total}`); + console.log(` duration: ${(r.totalMs / 1000).toFixed(2)}s`); + console.log(` throughput: ${r.rps.toFixed(0)} req/s`); + console.log(` success 2xx: ${ok2xx} (${((ok2xx / total) * 100).toFixed(1)}%)`); + console.log(` status codes: ${JSON.stringify(r.statusCounts)}`); + console.log(` latency avg: ${r.avg.toFixed(2)}ms p50: ${r.p50.toFixed(2)}ms p95: ${r.p95.toFixed(2)}ms p99: ${r.p99.toFixed(2)}ms max: ${r.max.toFixed(2)}ms`); +} + +async function main() { + const { requestsPerEndpoint, concurrency } = parseArgs(); + console.log(`Booting ephemeral server (in-memory Mongo, rate limits raised for this run only)...`); + const { mongod, database, server, port, token } = await setupServer(); + console.log(`Server up on 127.0.0.1:${port}. Running ${requestsPerEndpoint} requests/endpoint at concurrency ${concurrency}.`); + + try { + const targets = [ + { name: 'GET /health (no auth, no DB)', path: '/health', headers: {} }, + { name: 'GET /health/ready (real Mongo round-trip)', path: '/health/ready', headers: {} }, + { name: 'GET /api/v1/auth/me (JWT verify + DB read)', path: '/api/v1/auth/me', headers: { Authorization: `Bearer ${token}` } }, + ]; + + for (const t of targets) { + const r = await runBenchmark({ + host: '127.0.0.1', + port, + path: t.path, + headers: t.headers, + total: requestsPerEndpoint, + concurrency, + }); + report(t.name, r, requestsPerEndpoint); + } + } finally { + await new Promise((resolve) => server.close(resolve)); + await database.close(); + await mongod.stop(); + } +} + +main().catch((err) => { + console.error('Load test failed:', err); + process.exit(1); +}); diff --git a/node-api/scripts/mintLoadTestToken.js b/node-api/scripts/mintLoadTestToken.js new file mode 100644 index 0000000..ca3058f --- /dev/null +++ b/node-api/scripts/mintLoadTestToken.js @@ -0,0 +1,24 @@ +// Mints a long-lived access token for load testing, signed with the current +// .env.node JWT_SECRET — bypasses login (and its authLimiter budget) so a +// benchmark can hit protected routes without spending requests on auth. +// Never use the output against a production JWT_SECRET or commit it anywhere. +// +// Usage: +// node scripts/mintLoadTestToken.js [userId] [role] [institutionId] +// node scripts/mintLoadTestToken.js --ttl 4h +require('dotenv').config({ path: '.env.node' }); +const jwt = require('jsonwebtoken'); +const env = require('../src/config/env'); + +const args = process.argv.slice(2).filter((a) => a !== '--ttl'); +const ttlIndex = process.argv.indexOf('--ttl'); +const ttl = ttlIndex !== -1 ? process.argv[ttlIndex + 1] : '2h'; + +const [userId = '000000000000000000000000', role = 'student', institutionId = '000000000000000000000000'] = args; + +const payload = { sub: userId, role, institutionId, tv: 0 }; +const token = jwt.sign(payload, env.jwt.secret, { expiresIn: ttl }); + +console.log(token); +console.error(`\nMinted for sub=${userId} role=${role} institutionId=${institutionId}, expires in ${ttl}.`); +console.error('Use a real ObjectId for userId/institutionId if the endpoint under test does a DB lookup.'); diff --git a/node-api/scripts/setupIndexes.js b/node-api/scripts/setupIndexes.js index 08d8191..fb4d319 100644 --- a/node-api/scripts/setupIndexes.js +++ b/node-api/scripts/setupIndexes.js @@ -17,6 +17,12 @@ const INDEX_PLAN = { { key: { google_id: 1 }, options: { unique: true, sparse: true } }, { key: { role: 1 }, options: {} }, { key: { institution_id: 1 }, options: {} }, + // Sparse — set on only a small fraction of users at any time (an + // in-flight reset/verification link) — see user.repository.js's + // findByResetToken/findByEmailVerificationToken, both unindexed full + // collection scans before this. + { key: { reset_password_token_hash: 1 }, options: { sparse: true } }, + { key: { email_verification_token_hash: 1 }, options: { sparse: true } }, ], institutions: [ { key: { code: 1 }, options: { unique: true } }, @@ -120,6 +126,13 @@ const INDEX_PLAN = { { key: { sender_id: 1, receiver_id: 1, created_at: -1 }, options: {} }, { key: { receiver_id: 1, sender_id: 1, created_at: -1 }, options: {} }, ], + // Institution-agnostic practice-bank content — see models/question.model.js. + // question.repository.js#sampleRandom always $matches on category before + // $sample-ing; without this, every assigned-test draw was a full + // collection scan of the entire question bank before the random sample. + questions: [ + { key: { category: 1 }, options: {} }, + ], }; async function main() { diff --git a/node-api/src/__tests__/helpers/seed.js b/node-api/src/__tests__/helpers/seed.js new file mode 100644 index 0000000..c6651ea --- /dev/null +++ b/node-api/src/__tests__/helpers/seed.js @@ -0,0 +1,30 @@ +// Direct-to-repository seeding for integration tests — bypasses the HTTP +// register flow (which forces new institution_admin/hr accounts to +// `pending`, see auth.service.js#SELF_REGISTERABLE_ROLES) so tests can spin +// up an already-approved staff account without an extra super_admin step. +async function seedInstitution(institutionRepository, overrides = {}) { + return institutionRepository.create({ + name: 'Test Institute', + code: `TI-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`, + is_active: true, + ...overrides, + }); +} + +async function seedUser(userRepository, hashPassword, { role, institutionId, email, password = 'Sup3rSecret!', ...rest }) { + const password_hash = await hashPassword(password); + const user = await userRepository.create({ + email, + password_hash, + full_name: rest.full_name || 'Test User', + role, + institution_id: institutionId || null, + status: 'approved', + is_active: true, + token_version: 0, + ...rest, + }); + return { user, password }; +} + +module.exports = { seedInstitution, seedUser }; diff --git a/node-api/src/__tests__/helpers/testApp.js b/node-api/src/__tests__/helpers/testApp.js new file mode 100644 index 0000000..2ddbd7d --- /dev/null +++ b/node-api/src/__tests__/helpers/testApp.js @@ -0,0 +1,51 @@ +// Shared integration-test harness: spins up an in-memory MongoDB instance +// (mongodb-memory-server — no real database/network needed) and a fresh +// Express app wired to it. env.js/database.js read process.env at require +// time, so every env var below must be set *before* app.js is required — +// jest.resetModules() ensures each caller gets a clean module registry +// rather than reusing whatever a previous test file's require() cached. +const { MongoMemoryServer } = require('mongodb-memory-server'); + +let mongod; + +async function buildTestApp() { + mongod = await MongoMemoryServer.create(); + + process.env.MONGODB_URI = mongod.getUri(); + process.env.MONGODB_DB_NAME = 'test_upscaler_ai_node'; + process.env.JWT_SECRET = 'test-jwt-secret'; + process.env.JWT_REFRESH_SECRET = 'test-jwt-refresh-secret'; + process.env.JWT_EXPIRES_IN = '15m'; + process.env.JWT_REFRESH_EXPIRES_IN = '7d'; + process.env.FRONTEND_URL = 'http://localhost:3000'; + process.env.CORS_ORIGINS = 'http://localhost:3000'; + process.env.NODE_ENV = 'test'; + // High enough that a single test file's requests never trip the general + // limiter — auth-specific endpoints keep their own tighter limiter + // (20/15min) intentionally, so auth tests stay under that per file. + process.env.RATE_LIMIT_MAX = '1000'; + process.env.AUTH_RATE_LIMIT_MAX = '1000'; + + jest.resetModules(); + const database = require('../../config/database'); + await database.connect(); + const app = require('../../app'); + + // Same module registry as `app` above (both loaded after the + // jest.resetModules() call) — required here rather than re-required per + // test file so seeding writes to the same in-memory DB connection the + // app itself uses, not a second, disconnected instance. + const userRepository = require('../../repositories/user.repository'); + const institutionRepository = require('../../repositories/institution.repository'); + const studentRepository = require('../../repositories/student.repository'); + const { hashPassword } = require('../../utils/password'); + + return { app, database, userRepository, institutionRepository, studentRepository, hashPassword }; +} + +async function teardownTestApp(database) { + if (database) await database.close(); + if (mongod) await mongod.stop(); +} + +module.exports = { buildTestApp, teardownTestApp }; diff --git a/node-api/src/__tests__/integration/aiFeatures.test.js b/node-api/src/__tests__/integration/aiFeatures.test.js new file mode 100644 index 0000000..1fc1537 --- /dev/null +++ b/node-api/src/__tests__/integration/aiFeatures.test.js @@ -0,0 +1,93 @@ +const request = require('supertest'); +const { buildTestApp, teardownTestApp } = require('../helpers/testApp'); +const { seedInstitution, seedUser } = require('../helpers/seed'); + +// Regression coverage for the Phase 3 AI-service migration: interview +// question generation, resume AI features, and assessment question +// generation now proxy to the FastAPI ai-service (see +// utils/aiServiceClient.js) instead of calling Groq directly from node-api. +// The ai-service is never running during this Jest suite, so every call +// here genuinely fails to connect — this exercises the real +// AiServiceUnavailableError fallback path, not a mocked one. +describe('AI features: graceful degradation when the AI service is unreachable', () => { + let app; + let database; + let institutionRepository; + let userRepository; + let studentRepository; + let hashPassword; + + beforeAll(async () => { + ({ app, database, institutionRepository, userRepository, studentRepository, hashPassword } = await buildTestApp()); + }); + + afterAll(async () => { + await teardownTestApp(database); + }); + + async function login(email, password) { + const res = await request(app).post('/api/v1/auth/login').send({ email, password }).expect(200); + return res.body.data.accessToken; + } + + async function loginAsAdmin(institutionId) { + const { user, password } = await seedUser(userRepository, hashPassword, { + role: 'institution_admin', + institutionId, + email: `ai-admin-${Date.now()}-${Math.random()}@example.com`, + }); + return login(user.email, password); + } + + it('POST /interviews/generate falls back to local questions instead of failing', async () => { + const institution = await seedInstitution(institutionRepository, { code: `AII-${Date.now()}` }); + const token = await loginAsAdmin(institution.id); + + const res = await request(app) + .post('/api/v1/interviews/generate?role=Backend%20Engineer&company=Acme') + .set('Authorization', `Bearer ${token}`) + .expect(200); + + expect(res.body.data).toHaveLength(10); + expect(res.body.data.every((q) => q.type === 'technical')).toBe(true); + expect(res.body.data.every((q) => q.text.includes('ACME') && q.text.includes('BACKEND ENGINEER'))).toBe(true); + }); + + it('POST /tests/generate-questions falls back to a local placeholder question instead of failing', async () => { + const institution = await seedInstitution(institutionRepository, { code: `AIT-${Date.now()}` }); + const token = await loginAsAdmin(institution.id); + + const res = await request(app) + .post('/api/v1/tests/generate-questions') + .set('Authorization', `Bearer ${token}`) + .send({ title: 'Data Structures', type: 'quiz', difficulty: 'hard' }) + .expect(200); + + expect(res.body.data.questions).toHaveLength(1); + expect(res.body.data.questions[0].question).toContain('Data Structures'); + }); + + it('resume AI endpoints surface a 503 (not a crash) when the AI service is unreachable', async () => { + const institution = await seedInstitution(institutionRepository, { code: `AIR-${Date.now()}` }); + const { user: student, password } = await seedUser(userRepository, hashPassword, { + role: 'student', + institutionId: institution.id, + email: `ai-student-${Date.now()}@example.com`, + }); + await studentRepository.create({ + user_id: student.id, + institution_id: institution.id, + phone: '555-0100', + date_of_birth: '2000-01-01', + gender: 'female', + address: '123 Test St', + cgpa: 8.5, + }); + const token = await login(student.email, password); + + await request(app).get('/api/v1/resume').set('Authorization', `Bearer ${token}`).expect(200); + + const res = await request(app).post('/api/v1/resume/analyze').set('Authorization', `Bearer ${token}`).expect(503); + expect(res.body.message).toMatch(/unavailable/i); + }); +}); diff --git a/node-api/src/__tests__/integration/auth.test.js b/node-api/src/__tests__/integration/auth.test.js new file mode 100644 index 0000000..4a93c14 --- /dev/null +++ b/node-api/src/__tests__/integration/auth.test.js @@ -0,0 +1,203 @@ +const request = require('supertest'); +const jwt = require('jsonwebtoken'); +const { buildTestApp, teardownTestApp } = require('../helpers/testApp'); + +describe('Auth: register / login / refresh / change-password', () => { + let app; + let database; + // Required lazily, after buildTestApp()'s jest.resetModules() + connect() + // — requiring these at file top would grab a stale, disconnected instance + // of the module registry (same reasoning as testApp.js's own internal + // repository requires). + let userRepository; + let hashPassword; + + beforeAll(async () => { + ({ app, database } = await buildTestApp()); + userRepository = require('../../repositories/user.repository'); + ({ hashPassword } = require('../../utils/password')); + }); + + afterAll(async () => { + await teardownTestApp(database); + }); + + let emailCounter = 0; + function uniqueEmail(prefix) { + emailCounter += 1; + return `${prefix}-${emailCounter}@example.com`; + } + + // Fresh account per call (rather than one shared account across every + // `it()`) — this suite shares one in-memory DB for the whole file, so a + // reused email would 409 on the second call. + async function registerAndLogin(prefix = 'refresh-user') { + const email = uniqueEmail(prefix); + const password = 'Sup3rSecret!'; + await request(app).post('/api/v1/auth/register').send({ email, password, name: 'Refresh User' }).expect(201); + const res = await request(app).post('/api/v1/auth/login').send({ email, password }).expect(200); + return res.body.data; + } + + it('registers a new student and immediately issues tokens', async () => { + const res = await request(app) + .post('/api/v1/auth/register') + .send({ email: 'newbie@example.com', password: 'Sup3rSecret!', name: 'New Bie' }) + .expect(201); + expect(res.body.success).toBe(true); + expect(res.body.data.accessToken).toBeTruthy(); + expect(res.body.data.refreshToken).toBeTruthy(); + }); + + it('logs in with valid credentials and returns both token casings', async () => { + const data = await registerAndLogin(); + expect(data.accessToken).toBeTruthy(); + expect(data.access_token).toBe(data.accessToken); // legacy-field compat, see auth.controller.js + expect(data.user.email).toBeTruthy(); + }); + + it('rejects login with the wrong password', async () => { + const email = uniqueEmail('wrong-pw'); + await request(app).post('/api/v1/auth/register').send({ email, password: 'Sup3rSecret!', name: 'Wrong Pw' }).expect(201); + await request(app).post('/api/v1/auth/login').send({ email, password: 'wrong-password' }).expect(401); + }); + + // Regression test for C-1: the live frontend's bare-axios refresh call + // sends `refresh_token` (snake_case) and reads access_token/refresh_token + // off the top level of the response body — see api.ts. Both used to be + // silently broken (Joi stripped the unrecognized field; the token fields + // were nested a level too deep). + it('refreshes with a snake_case refresh_token body and flat top-level token fields', async () => { + const { refreshToken } = await registerAndLogin(); + + const res = await request(app) + .post('/api/v1/auth/refresh') + .send({ refresh_token: refreshToken }) + .expect(200); + + expect(res.body.access_token).toBeTruthy(); + expect(res.body.refresh_token).toBeTruthy(); + // Envelope form still present for every other consumer. + expect(res.body.data.accessToken).toBe(res.body.access_token); + }); + + it('also accepts the camelCase refreshToken body', async () => { + const { refreshToken } = await registerAndLogin(); + const res = await request(app).post('/api/v1/auth/refresh').send({ refreshToken }).expect(200); + expect(res.body.access_token).toBeTruthy(); + }); + + it('supports refreshing repeatedly without forcing a re-login', async () => { + const { refreshToken: first } = await registerAndLogin(); + const res1 = await request(app).post('/api/v1/auth/refresh').send({ refresh_token: first }).expect(200); + const second = res1.body.refresh_token; + const res2 = await request(app).post('/api/v1/auth/refresh').send({ refresh_token: second }).expect(200); + expect(res2.body.access_token).toBeTruthy(); + }); + + it('rejects a malformed/invalid refresh token', async () => { + await request(app) + .post('/api/v1/auth/refresh') + .send({ refresh_token: 'not-a-real-token' }) + .expect(401); + }); + + it('rejects an expired refresh token', async () => { + const expired = jwt.sign({ sub: 'someone', tv: 0 }, process.env.JWT_REFRESH_SECRET, { expiresIn: -10 }); + await request(app).post('/api/v1/auth/refresh').send({ refresh_token: expired }).expect(401); + }); + + it('rejects a refresh request with neither field present', async () => { + await request(app).post('/api/v1/auth/refresh').send({}).expect(400); + }); + + // Regression test for C-3. + it('changes password, rejects the wrong current password, and invalidates the old refresh token', async () => { + const email = uniqueEmail('changer'); + await request(app).post('/api/v1/auth/register').send({ email, password: 'OldPass123!', name: 'Changer' }).expect(201); + const login = await request(app).post('/api/v1/auth/login').send({ email, password: 'OldPass123!' }).expect(200); + const { accessToken, refreshToken } = login.body.data; + + await request(app) + .put('/api/v1/auth/change-password') + .set('Authorization', `Bearer ${accessToken}`) + .send({ current_password: 'wrong', new_password: 'NewPass123!' }) + .expect(400); + + const changeRes = await request(app) + .put('/api/v1/auth/change-password') + .set('Authorization', `Bearer ${accessToken}`) + .send({ current_password: 'OldPass123!', new_password: 'NewPass123!' }) + .expect(200); + expect(changeRes.body.data.accessToken).toBeTruthy(); + + // Old refresh token was issued before the password change and must now + // be rejected (token_version bump) — see auth.service.js#changePassword. + await request(app).post('/api/v1/auth/refresh').send({ refresh_token: refreshToken }).expect(401); + + // New credentials work. + await request(app).post('/api/v1/auth/login').send({ email, password: 'NewPass123!' }).expect(200); + }); + + it('rejects change-password to the same password', async () => { + const email = uniqueEmail('samepass'); + await request(app).post('/api/v1/auth/register').send({ email, password: 'Password123!', name: 'Same Pass' }).expect(201); + const login = await request(app).post('/api/v1/auth/login').send({ email, password: 'Password123!' }).expect(200); + + await request(app) + .put('/api/v1/auth/change-password') + .set('Authorization', `Bearer ${login.body.data.accessToken}`) + .send({ current_password: 'Password123!', new_password: 'Password123!' }) + .expect(400); + }); + + it('rejects change-password without authentication', async () => { + await request(app) + .put('/api/v1/auth/change-password') + .send({ current_password: 'a', new_password: 'Password123!' }) + .expect(401); + }); + + // Regression test: an account whose DB row still carries the legacy + // python-service role string `college_admin` (e.g. seeded/migrated + // outside the register() flow, which is the only place that normalizes + // role on write) must still log in with a token mapped to the current + // `institution_admin` vocabulary — otherwise authorize() rejects every + // institution_admin-gated route with a 403 forever, since login just + // re-signs whatever role is on the row. See auth.service.js#issueTokens. + it('normalizes a legacy `college_admin` DB role to `institution_admin` on login', async () => { + const email = uniqueEmail('legacy-role-admin'); + const password = 'Sup3rSecret!'; + await userRepository.create({ + email, + password_hash: await hashPassword(password), + full_name: 'Legacy Role Admin', + role: 'college_admin', + }); + + const res = await request(app).post('/api/v1/auth/login').send({ email, password }).expect(200); + const decoded = jwt.decode(res.body.data.accessToken); + expect(decoded.role).toBe('institution_admin'); + }); + + // Same fix, but for a token that was already signed with the legacy role + // before this fix existed (or before a DB row gets backfilled) — authenticate() + // must normalize it per-request too, so a currently active session + // self-heals without forcing a re-login. GET /dashboard/admin is gated + // with authorize(FACULTY, INSTITUTION_ADMIN, SUPER_ADMIN); the bug this + // guards against is authorize() seeing the raw `college_admin` string and + // rejecting with 403 — anything else proves it was mapped and accepted. + it('normalizes a legacy `college_admin` role from an already-issued token in authenticate()', async () => { + const legacyToken = jwt.sign( + { sub: 'legacy-user-id', role: 'college_admin', institutionId: 'legacy-institution-id', tv: 0 }, + process.env.JWT_SECRET, + { expiresIn: '5m' } + ); + + const res = await request(app) + .get('/api/v1/dashboard/admin') + .set('Authorization', `Bearer ${legacyToken}`); + + expect(res.status).not.toBe(403); + }); +}); diff --git a/node-api/src/__tests__/integration/collegeAdmins.test.js b/node-api/src/__tests__/integration/collegeAdmins.test.js new file mode 100644 index 0000000..29a9107 --- /dev/null +++ b/node-api/src/__tests__/integration/collegeAdmins.test.js @@ -0,0 +1,99 @@ +const request = require('supertest'); +const { buildTestApp, teardownTestApp } = require('../helpers/testApp'); +const { seedInstitution, seedUser } = require('../helpers/seed'); + +// Regression test: college-admin listing used the raw BaseService.list() +// with no institution scoping at all — GET /college-admins silently +// returned every institution's rows to any authenticated institution_admin. +// Surfaced by the Express 5 migration (req.query mutation, previously used +// elsewhere for this kind of scoping, silently stopped working), but this +// entity was never scoped via req.query to begin with — it just had no +// scoping whatsoever, in any Express version. +describe('College Admins: institution-scoped listing', () => { + let app; + let database; + let institutionRepository; + let userRepository; + let hashPassword; + + beforeAll(async () => { + ({ app, database, institutionRepository, userRepository, hashPassword } = await buildTestApp()); + }); + + afterAll(async () => { + await teardownTestApp(database); + }); + + async function login(email, password) { + const res = await request(app).post('/api/v1/auth/login').send({ email, password }).expect(200); + return res.body.data.accessToken; + } + + it('never leaks another institution\'s college-admin rows into the list', async () => { + const institutionA = await seedInstitution(institutionRepository, { code: `CAA-${Date.now()}` }); + const institutionB = await seedInstitution(institutionRepository, { code: `CAB-${Date.now()}` }); + + const { user: superAdmin, password: superPassword } = await seedUser(userRepository, hashPassword, { + role: 'super_admin', + email: `super-ca-${Date.now()}@example.com`, + }); + const superToken = await login(superAdmin.email, superPassword); + + const { user: adminAUser } = await seedUser(userRepository, hashPassword, { + role: 'institution_admin', + institutionId: institutionA.id, + email: `ca-user-a-${Date.now()}@example.com`, + }); + const { user: adminBUser } = await seedUser(userRepository, hashPassword, { + role: 'institution_admin', + institutionId: institutionB.id, + email: `ca-user-b-${Date.now()}@example.com`, + }); + + await request(app) + .post('/api/v1/college-admins') + .set('Authorization', `Bearer ${superToken}`) + .send({ user_id: adminAUser.id, institution_id: institutionA.id, designation: 'Registrar A' }) + .expect(201); + await request(app) + .post('/api/v1/college-admins') + .set('Authorization', `Bearer ${superToken}`) + .send({ user_id: adminBUser.id, institution_id: institutionB.id, designation: 'Registrar B' }) + .expect(201); + + const { user: viewerAdminA, password: viewerPassword } = await seedUser(userRepository, hashPassword, { + role: 'institution_admin', + institutionId: institutionA.id, + email: `ca-viewer-${Date.now()}@example.com`, + }); + const viewerToken = await login(viewerAdminA.email, viewerPassword); + + const res = await request(app).get('/api/v1/college-admins').set('Authorization', `Bearer ${viewerToken}`).expect(200); + const designations = res.body.data.map((r) => r.designation); + expect(designations).toContain('Registrar A'); + expect(designations).not.toContain('Registrar B'); + }); + + it('super_admin sees college-admins across every institution', async () => { + const institution = await seedInstitution(institutionRepository, { code: `CAS-${Date.now()}` }); + const { user: superAdmin, password } = await seedUser(userRepository, hashPassword, { + role: 'super_admin', + email: `super-ca2-${Date.now()}@example.com`, + }); + const token = await login(superAdmin.email, password); + + const { user: targetUser } = await seedUser(userRepository, hashPassword, { + role: 'institution_admin', + institutionId: institution.id, + email: `ca-target-${Date.now()}@example.com`, + }); + await request(app) + .post('/api/v1/college-admins') + .set('Authorization', `Bearer ${token}`) + .send({ user_id: targetUser.id, institution_id: institution.id, designation: 'Cross-visible Registrar' }) + .expect(201); + + const res = await request(app).get('/api/v1/college-admins').set('Authorization', `Bearer ${token}`).expect(200); + expect(res.body.data.map((r) => r.designation)).toContain('Cross-visible Registrar'); + }); +}); diff --git a/node-api/src/__tests__/integration/departments.test.js b/node-api/src/__tests__/integration/departments.test.js new file mode 100644 index 0000000..1900ccb --- /dev/null +++ b/node-api/src/__tests__/integration/departments.test.js @@ -0,0 +1,111 @@ +const request = require('supertest'); +const { buildTestApp, teardownTestApp } = require('../helpers/testApp'); +const { seedInstitution, seedUser } = require('../helpers/seed'); + +describe('Departments: CRUD, RBAC, cross-tenant isolation', () => { + let app; + let database; + let institutionRepository; + let userRepository; + let hashPassword; + + beforeAll(async () => { + ({ app, database, institutionRepository, userRepository, hashPassword } = await buildTestApp()); + }); + + afterAll(async () => { + await teardownTestApp(database); + }); + + async function login(email, password) { + const res = await request(app).post('/api/v1/auth/login').send({ email, password }).expect(200); + return res.body.data.accessToken; + } + + async function loginAsAdmin(institutionId) { + const { user, password } = await seedUser(userRepository, hashPassword, { + role: 'institution_admin', + institutionId, + email: `dept-admin-${Date.now()}-${Math.random()}@example.com`, + }); + return login(user.email, password); + } + + it('institution_admin can create/update/delete a department in their own institution', async () => { + const institution = await seedInstitution(institutionRepository); + const token = await loginAsAdmin(institution.id); + + const createRes = await request(app) + .post('/api/v1/departments') + .set('Authorization', `Bearer ${token}`) + .send({ institution_id: institution.id, name: 'Computer Science', code: 'CS' }) + .expect(201); + const deptId = createRes.body.data.id; + + await request(app) + .put(`/api/v1/departments/${deptId}`) + .set('Authorization', `Bearer ${token}`) + .send({ name: 'CS & Engineering' }) + .expect(200); + + await request(app).delete(`/api/v1/departments/${deptId}`).set('Authorization', `Bearer ${token}`).expect(200); + await request(app).get(`/api/v1/departments/${deptId}`).set('Authorization', `Bearer ${token}`).expect(404); + }); + + it('student/faculty cannot create, update, or delete departments', async () => { + const institution = await seedInstitution(institutionRepository); + const { user, password } = await seedUser(userRepository, hashPassword, { + role: 'faculty', + institutionId: institution.id, + email: `faculty-${Date.now()}@example.com`, + }); + const token = await login(user.email, password); + + await request(app) + .post('/api/v1/departments') + .set('Authorization', `Bearer ${token}`) + .send({ institution_id: institution.id, name: 'Should Fail', code: 'SF' }) + .expect(403); + }); + + it('scopeInstitution forces institution_id server-side, ignoring a spoofed value from a non-super_admin', async () => { + const institutionA = await seedInstitution(institutionRepository, { code: `DA-${Date.now()}` }); + const institutionB = await seedInstitution(institutionRepository, { code: `DB-${Date.now()}` }); + const tokenA = await loginAsAdmin(institutionA.id); + + const res = await request(app) + .post('/api/v1/departments') + .set('Authorization', `Bearer ${tokenA}`) + .send({ institution_id: institutionB.id, name: 'Spoofed Dept', code: 'SPOOF' }) + .expect(201); + + expect(res.body.data.institution_id).toBe(institutionA.id); + }); + + it('a department created in institution A is never visible when listing as institution B', async () => { + const institutionA = await seedInstitution(institutionRepository, { code: `IA-${Date.now()}` }); + const institutionB = await seedInstitution(institutionRepository, { code: `IB-${Date.now()}` }); + const tokenA = await loginAsAdmin(institutionA.id); + const tokenB = await loginAsAdmin(institutionB.id); + + await request(app) + .post('/api/v1/departments') + .set('Authorization', `Bearer ${tokenA}`) + .send({ institution_id: institutionA.id, name: 'A-Only Department', code: `AO-${Date.now()}` }) + .expect(201); + + const listB = await request(app).get('/api/v1/departments').set('Authorization', `Bearer ${tokenB}`).expect(200); + const names = listB.body.data.map((d) => d.name); + expect(names).not.toContain('A-Only Department'); + }); + + it('rejects department creation with a missing required field', async () => { + const institution = await seedInstitution(institutionRepository); + const token = await loginAsAdmin(institution.id); + await request(app) + .post('/api/v1/departments') + .set('Authorization', `Bearer ${token}`) + .send({ institution_id: institution.id, name: 'No Code' }) + .expect(400); + }); +}); diff --git a/node-api/src/__tests__/integration/faculty.test.js b/node-api/src/__tests__/integration/faculty.test.js new file mode 100644 index 0000000..424918d --- /dev/null +++ b/node-api/src/__tests__/integration/faculty.test.js @@ -0,0 +1,112 @@ +const request = require('supertest'); +const { buildTestApp, teardownTestApp } = require('../helpers/testApp'); +const { seedInstitution, seedUser } = require('../helpers/seed'); + +// Regression test: faculty listing used the raw BaseService.list() with no +// institution scoping at all — GET /faculty silently returned every +// institution's faculty rows to any authenticated caller. Surfaced by the +// Express 5 migration (see departments.test.js for the root cause), but +// this entity was never scoped via req.query to begin with — it just had +// no scoping whatsoever, in any Express version. +describe('Faculty: institution-scoped listing', () => { + let app; + let database; + let institutionRepository; + let userRepository; + let hashPassword; + + beforeAll(async () => { + ({ app, database, institutionRepository, userRepository, hashPassword } = await buildTestApp()); + }); + + afterAll(async () => { + await teardownTestApp(database); + }); + + async function login(email, password) { + const res = await request(app).post('/api/v1/auth/login').send({ email, password }).expect(200); + return res.body.data.accessToken; + } + + async function loginAsAdmin(institutionId) { + const { user, password } = await seedUser(userRepository, hashPassword, { + role: 'institution_admin', + institutionId, + email: `fac-admin-${Date.now()}-${Math.random()}@example.com`, + }); + return login(user.email, password); + } + + it('never leaks another institution\'s faculty rows into the list', async () => { + const institutionA = await seedInstitution(institutionRepository, { code: `FA-${Date.now()}` }); + const institutionB = await seedInstitution(institutionRepository, { code: `FB-${Date.now()}` }); + const tokenA = await loginAsAdmin(institutionA.id); + const tokenB = await loginAsAdmin(institutionB.id); + + const { user: facultyUserA } = await seedUser(userRepository, hashPassword, { + role: 'faculty', + institutionId: institutionA.id, + email: `fac-user-a-${Date.now()}@example.com`, + }); + const { user: facultyUserB } = await seedUser(userRepository, hashPassword, { + role: 'faculty', + institutionId: institutionB.id, + email: `fac-user-b-${Date.now()}@example.com`, + }); + + await request(app) + .post('/api/v1/faculty') + .set('Authorization', `Bearer ${tokenA}`) + .send({ user_id: facultyUserA.id, institution_id: institutionA.id, designation: 'Professor A' }) + .expect(201); + await request(app) + .post('/api/v1/faculty') + .set('Authorization', `Bearer ${tokenB}`) + .send({ user_id: facultyUserB.id, institution_id: institutionB.id, designation: 'Professor B' }) + .expect(201); + + const listA = await request(app).get('/api/v1/faculty').set('Authorization', `Bearer ${tokenA}`).expect(200); + const designations = listA.body.data.map((r) => r.designation); + expect(designations).toContain('Professor A'); + expect(designations).not.toContain('Professor B'); + }); + + it('super_admin sees faculty across every institution', async () => { + const institution = await seedInstitution(institutionRepository, { code: `FS-${Date.now()}` }); + const { user: superAdmin, password } = await seedUser(userRepository, hashPassword, { + role: 'super_admin', + email: `super-fac-${Date.now()}@example.com`, + }); + const superToken = await login(superAdmin.email, password); + + const { user: facultyUser } = await seedUser(userRepository, hashPassword, { + role: 'faculty', + institutionId: institution.id, + email: `fac-target-${Date.now()}@example.com`, + }); + await request(app) + .post('/api/v1/faculty') + .set('Authorization', `Bearer ${superToken}`) + .send({ user_id: facultyUser.id, institution_id: institution.id, designation: 'Cross-visible Professor' }) + .expect(201); + + const res = await request(app).get('/api/v1/faculty').set('Authorization', `Bearer ${superToken}`).expect(200); + expect(res.body.data.map((r) => r.designation)).toContain('Cross-visible Professor'); + }); + + it('student cannot create a faculty profile', async () => { + const institution = await seedInstitution(institutionRepository, { code: `FST-${Date.now()}` }); + const { user, password } = await seedUser(userRepository, hashPassword, { + role: 'student', + institutionId: institution.id, + email: `fac-student-${Date.now()}@example.com`, + }); + const token = await login(user.email, password); + + await request(app) + .post('/api/v1/faculty') + .set('Authorization', `Bearer ${token}`) + .send({ user_id: user.id, institution_id: institution.id, designation: 'Should Fail' }) + .expect(403); + }); +}); diff --git a/node-api/src/__tests__/integration/hr-jobs.test.js b/node-api/src/__tests__/integration/hr-jobs.test.js new file mode 100644 index 0000000..ab87cdc --- /dev/null +++ b/node-api/src/__tests__/integration/hr-jobs.test.js @@ -0,0 +1,83 @@ +const request = require('supertest'); +const { buildTestApp, teardownTestApp } = require('../helpers/testApp'); +const { seedInstitution, seedUser } = require('../helpers/seed'); + +// Regression tests for C-2: the live HR portal (hr/page.tsx) calls /jobs, +// /jobs/me, /jobs/applications/me and POSTs to /jobs — none of which existed +// before /jobs was aliased onto the placement router. +describe('HR portal: /jobs alias and payload shape', () => { + let app; + let database; + let institutionRepository; + let userRepository; + let hashPassword; + + beforeAll(async () => { + ({ app, database, institutionRepository, userRepository, hashPassword } = await buildTestApp()); + }); + + afterAll(async () => { + await teardownTestApp(database); + }); + + async function loginAsHr() { + const institution = await seedInstitution(institutionRepository); + const { user, password } = await seedUser(userRepository, hashPassword, { + role: 'hr', + institutionId: institution.id, + email: `hr-${Date.now()}@example.com`, + }); + const res = await request(app).post('/api/v1/auth/login').send({ email: user.email, password }).expect(200); + return res.body.data.accessToken; + } + + it('accepts a vacancy post at /jobs with the frontend\'s actual payload shape (comma-separated strings)', async () => { + const token = await loginAsHr(); + + const res = await request(app) + .post('/api/v1/jobs') + .set('Authorization', `Bearer ${token}`) + .send({ + title: 'Full Stack Developer', + job_type: 'full_time', + application_deadline: new Date(Date.now() + 86400000).toISOString(), + min_cgpa: 7, + eligible_years: '2024, 2025', + description: 'Build things.', + required_skills: 'React, Node.js', + company_name: 'Acme Corp', + }) + .expect(201); + + expect(res.body.data.title).toBe('Full Stack Developer'); + expect(res.body.data.required_skills).toEqual(['React', 'Node.js']); + expect(res.body.data.eligible_years).toEqual([2024, 2025]); + }); + + it('lists postings under /jobs and /placements identically', async () => { + const token = await loginAsHr(); + await request(app) + .post('/api/v1/jobs') + .set('Authorization', `Bearer ${token}`) + .send({ title: 'Backend Engineer', company_name: 'Acme' }) + .expect(201); + + const viaJobs = await request(app).get('/api/v1/jobs/me').set('Authorization', `Bearer ${token}`).expect(200); + const viaPlacements = await request(app) + .get('/api/v1/placements/me') + .set('Authorization', `Bearer ${token}`) + .expect(200); + + expect(Array.isArray(viaJobs.body.data)).toBe(true); + expect(viaJobs.body.data.map((p) => p.id).sort()).toEqual(viaPlacements.body.data.map((p) => p.id).sort()); + }); + + it('/jobs/applications/me responds for a recruiter (empty, but not 404)', async () => { + const token = await loginAsHr(); + const res = await request(app) + .get('/api/v1/jobs/applications/me') + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(Array.isArray(res.body.data)).toBe(true); + }); +}); diff --git a/node-api/src/__tests__/integration/institutions.test.js b/node-api/src/__tests__/integration/institutions.test.js new file mode 100644 index 0000000..cc16f72 --- /dev/null +++ b/node-api/src/__tests__/integration/institutions.test.js @@ -0,0 +1,115 @@ +const request = require('supertest'); +const { buildTestApp, teardownTestApp } = require('../helpers/testApp'); +const { seedInstitution, seedUser } = require('../helpers/seed'); + +describe('Institutions: CRUD, RBAC, public listing', () => { + let app; + let database; + let institutionRepository; + let userRepository; + let hashPassword; + + beforeAll(async () => { + ({ app, database, institutionRepository, userRepository, hashPassword } = await buildTestApp()); + }); + + afterAll(async () => { + await teardownTestApp(database); + }); + + async function login(email, password) { + const res = await request(app).post('/api/v1/auth/login').send({ email, password }).expect(200); + return res.body.data.accessToken; + } + + it('GET /institutions/public works with no auth and only returns active institutions', async () => { + await seedInstitution(institutionRepository, { name: 'Active Institute', code: `ACT-${Date.now()}`, is_active: true }); + await seedInstitution(institutionRepository, { name: 'Inactive Institute', code: `INA-${Date.now()}`, is_active: false }); + + const res = await request(app).get('/api/v1/institutions/public').expect(200); + const names = res.body.data.map((i) => i.name); + expect(names).toContain('Active Institute'); + expect(names).not.toContain('Inactive Institute'); + }); + + it('GET /institutions requires authentication', async () => { + await request(app).get('/api/v1/institutions').expect(401); + }); + + it('GET /institutions is forbidden for student/faculty roles', async () => { + const institution = await seedInstitution(institutionRepository); + const { user, password } = await seedUser(userRepository, hashPassword, { + role: 'student', + institutionId: institution.id, + email: `student-${Date.now()}@example.com`, + }); + const token = await login(user.email, password); + await request(app).get('/api/v1/institutions').set('Authorization', `Bearer ${token}`).expect(403); + }); + + it('only super_admin can create/update/delete an institution', async () => { + const institution = await seedInstitution(institutionRepository); + const { user: admin, password: adminPassword } = await seedUser(userRepository, hashPassword, { + role: 'institution_admin', + institutionId: institution.id, + email: `inst-admin-${Date.now()}@example.com`, + }); + const adminToken = await login(admin.email, adminPassword); + + await request(app) + .post('/api/v1/institutions') + .set('Authorization', `Bearer ${adminToken}`) + .send({ name: 'Should Fail', code: `SF-${Date.now()}` }) + .expect(403); + + const { user: superAdmin, password: superPassword } = await seedUser(userRepository, hashPassword, { + role: 'super_admin', + email: `super-${Date.now()}@example.com`, + }); + const superToken = await login(superAdmin.email, superPassword); + + const createRes = await request(app) + .post('/api/v1/institutions') + .set('Authorization', `Bearer ${superToken}`) + .send({ name: 'New Institute', code: `NI-${Date.now()}` }) + .expect(201); + const newId = createRes.body.data.id; + + await request(app) + .put(`/api/v1/institutions/${newId}`) + .set('Authorization', `Bearer ${adminToken}`) + .send({ name: 'Hijacked' }) + .expect(403); + + await request(app) + .put(`/api/v1/institutions/${newId}`) + .set('Authorization', `Bearer ${superToken}`) + .send({ name: 'Renamed Institute' }) + .expect(200); + + await request(app).delete(`/api/v1/institutions/${newId}`).set('Authorization', `Bearer ${adminToken}`).expect(403); + await request(app).delete(`/api/v1/institutions/${newId}`).set('Authorization', `Bearer ${superToken}`).expect(200); + await request(app).get(`/api/v1/institutions/${newId}`).set('Authorization', `Bearer ${superToken}`).expect(404); + }); + + it('rejects institution creation with missing required fields', async () => { + const { user: superAdmin, password } = await seedUser(userRepository, hashPassword, { + role: 'super_admin', + email: `super-val-${Date.now()}@example.com`, + }); + const token = await login(superAdmin.email, password); + await request(app).post('/api/v1/institutions').set('Authorization', `Bearer ${token}`).send({ name: 'No Code' }).expect(400); + }); + + it('GET /institutions/:id returns 404 for a nonexistent id', async () => { + const { user: superAdmin, password } = await seedUser(userRepository, hashPassword, { + role: 'super_admin', + email: `super-404-${Date.now()}@example.com`, + }); + const token = await login(superAdmin.email, password); + await request(app) + .get('/api/v1/institutions/00000000-0000-0000-0000-000000000000') + .set('Authorization', `Bearer ${token}`) + .expect(404); + }); +}); diff --git a/node-api/src/__tests__/integration/notifications.test.js b/node-api/src/__tests__/integration/notifications.test.js new file mode 100644 index 0000000..3302a73 --- /dev/null +++ b/node-api/src/__tests__/integration/notifications.test.js @@ -0,0 +1,135 @@ +const request = require('supertest'); +const { buildTestApp, teardownTestApp } = require('../helpers/testApp'); +const { seedInstitution, seedUser } = require('../helpers/seed'); + +describe('Notifications: personal inbox, RBAC, cross-tenant protection', () => { + let app; + let database; + let institutionRepository; + let userRepository; + let hashPassword; + + beforeAll(async () => { + ({ app, database, institutionRepository, userRepository, hashPassword } = await buildTestApp()); + }); + + afterAll(async () => { + await teardownTestApp(database); + }); + + async function login(email, password) { + const res = await request(app).post('/api/v1/auth/login').send({ email, password }).expect(200); + return res.body.data.accessToken; + } + + it('a student cannot create notifications for others', async () => { + const institution = await seedInstitution(institutionRepository); + const { user: student, password } = await seedUser(userRepository, hashPassword, { + role: 'student', + institutionId: institution.id, + email: `student-${Date.now()}@example.com`, + }); + const token = await login(student.email, password); + + await request(app) + .post('/api/v1/notifications') + .set('Authorization', `Bearer ${token}`) + .send({ user_id: student.id, title: 'Hi', message: 'Test' }) + .expect(403); + }); + + it('institution_admin can notify a user in their own institution but not another', async () => { + const institutionA = await seedInstitution(institutionRepository, { code: `NA-${Date.now()}` }); + const institutionB = await seedInstitution(institutionRepository, { code: `NB-${Date.now()}` }); + const { user: admin, password: adminPassword } = await seedUser(userRepository, hashPassword, { + role: 'institution_admin', + institutionId: institutionA.id, + email: `notif-admin-${Date.now()}@example.com`, + }); + const { user: studentA } = await seedUser(userRepository, hashPassword, { + role: 'student', + institutionId: institutionA.id, + email: `student-a-${Date.now()}@example.com`, + }); + const { user: studentB } = await seedUser(userRepository, hashPassword, { + role: 'student', + institutionId: institutionB.id, + email: `student-b-${Date.now()}@example.com`, + }); + const token = await login(admin.email, adminPassword); + + await request(app) + .post('/api/v1/notifications') + .set('Authorization', `Bearer ${token}`) + .send({ user_id: studentA.id, title: 'Welcome', message: 'Hello A' }) + .expect(201); + + await request(app) + .post('/api/v1/notifications') + .set('Authorization', `Bearer ${token}`) + .send({ user_id: studentB.id, title: 'Cross tenant', message: 'Should fail' }) + .expect(403); + }); + + it('GET /notifications only ever returns the caller\'s own, and PATCH :id/read is owner-only', async () => { + const institution = await seedInstitution(institutionRepository); + const { user: admin, password: adminPassword } = await seedUser(userRepository, hashPassword, { + role: 'institution_admin', + institutionId: institution.id, + email: `admin2-${Date.now()}@example.com`, + }); + const { user: studentOne, password: studentOnePassword } = await seedUser(userRepository, hashPassword, { + role: 'student', + institutionId: institution.id, + email: `s1-${Date.now()}@example.com`, + }); + const { user: studentTwo, password: studentTwoPassword } = await seedUser(userRepository, hashPassword, { + role: 'student', + institutionId: institution.id, + email: `s2-${Date.now()}@example.com`, + }); + const adminToken = await login(admin.email, adminPassword); + + const createRes = await request(app) + .post('/api/v1/notifications') + .set('Authorization', `Bearer ${adminToken}`) + .send({ user_id: studentOne.id, title: 'For student one', message: 'msg' }) + .expect(201); + const notificationId = createRes.body.data.id; + + const tokenOne = await login(studentOne.email, studentOnePassword); + const tokenTwo = await login(studentTwo.email, studentTwoPassword); + + const listTwo = await request(app).get('/api/v1/notifications').set('Authorization', `Bearer ${tokenTwo}`).expect(200); + expect(listTwo.body.data.find((n) => n.id === notificationId)).toBeUndefined(); + + const listOne = await request(app).get('/api/v1/notifications').set('Authorization', `Bearer ${tokenOne}`).expect(200); + expect(listOne.body.data.find((n) => n.id === notificationId)).toBeTruthy(); + + await request(app) + .patch(`/api/v1/notifications/${notificationId}/read`) + .set('Authorization', `Bearer ${tokenTwo}`) + .expect(403); + + await request(app) + .patch(`/api/v1/notifications/${notificationId}/read`) + .set('Authorization', `Bearer ${tokenOne}`) + .expect(200); + }); + + it('rejects notification creation targeting a nonexistent user', async () => { + const institution = await seedInstitution(institutionRepository); + const { user: admin, password } = await seedUser(userRepository, hashPassword, { + role: 'institution_admin', + institutionId: institution.id, + email: `admin3-${Date.now()}@example.com`, + }); + const token = await login(admin.email, password); + + await request(app) + .post('/api/v1/notifications') + .set('Authorization', `Bearer ${token}`) + .send({ user_id: '00000000-0000-0000-0000-000000000000', title: 'Ghost', message: 'msg' }) + .expect(400); + }); +}); diff --git a/node-api/src/__tests__/integration/pagination.test.js b/node-api/src/__tests__/integration/pagination.test.js new file mode 100644 index 0000000..e770efa --- /dev/null +++ b/node-api/src/__tests__/integration/pagination.test.js @@ -0,0 +1,95 @@ +const request = require('supertest'); +const { buildTestApp, teardownTestApp } = require('../helpers/testApp'); +const { seedInstitution, seedUser } = require('../helpers/seed'); + +// Regression tests for H-2: BaseService.list/ApiResponse.paginated used to +// report page/limit/total/totalPages with no hasNext/hasPrevious and no +// sort support. +describe('Pagination: GET /institutions', () => { + let app; + let database; + let institutionRepository; + let userRepository; + let hashPassword; + + beforeAll(async () => { + ({ app, database, institutionRepository, userRepository, hashPassword } = await buildTestApp()); + + for (let i = 0; i < 25; i += 1) { + await seedInstitution(institutionRepository, { name: `Institute ${String(i).padStart(2, '0')}`, code: `INST-${i}-${Date.now()}` }); + } + }); + + afterAll(async () => { + await teardownTestApp(database); + }); + + async function loginAsSuperAdmin() { + const { user, password } = await seedUser(userRepository, hashPassword, { + role: 'super_admin', + email: `super-${Date.now()}-${Math.random()}@example.com`, + }); + const res = await request(app).post('/api/v1/auth/login').send({ email: user.email, password }).expect(200); + return res.body.data.accessToken; + } + + it('returns real pagination metadata, not a silently truncated array', async () => { + const token = await loginAsSuperAdmin(); + const res = await request(app) + .get('/api/v1/institutions') + .query({ page: 1, limit: 10 }) + .set('Authorization', `Bearer ${token}`) + .expect(200); + + expect(res.body.data.length).toBe(10); + expect(res.body.meta).toMatchObject({ page: 1, limit: 10 }); + expect(res.body.meta.total).toBeGreaterThanOrEqual(25); + expect(res.body.meta.totalPages).toBeGreaterThanOrEqual(3); + expect(res.body.meta.hasNext).toBe(true); + expect(res.body.meta.hasPrevious).toBe(false); + }); + + it('hasPrevious is true and hasNext is false on the last page', async () => { + const token = await loginAsSuperAdmin(); + const first = await request(app) + .get('/api/v1/institutions') + .query({ page: 1, limit: 10 }) + .set('Authorization', `Bearer ${token}`) + .expect(200); + const lastPage = first.body.meta.totalPages; + + const res = await request(app) + .get('/api/v1/institutions') + .query({ page: lastPage, limit: 10 }) + .set('Authorization', `Bearer ${token}`) + .expect(200); + + expect(res.body.meta.hasNext).toBe(false); + expect(res.body.meta.hasPrevious).toBe(true); + }); + + it('supports sortBy/sortOrder', async () => { + const token = await loginAsSuperAdmin(); + const asc = await request(app) + .get('/api/v1/institutions') + .query({ page: 1, limit: 5, sortBy: 'name', sortOrder: 'asc' }) + .set('Authorization', `Bearer ${token}`) + .expect(200); + const desc = await request(app) + .get('/api/v1/institutions') + .query({ page: 1, limit: 5, sortBy: 'name', sortOrder: 'desc' }) + .set('Authorization', `Bearer ${token}`) + .expect(200); + + expect(asc.body.data[0].name).not.toBe(desc.body.data[0].name); + }); + + it('ignores an unrecognized sortBy rather than erroring', async () => { + const token = await loginAsSuperAdmin(); + await request(app) + .get('/api/v1/institutions') + .query({ page: 1, limit: 5, sortBy: '$where' }) + .set('Authorization', `Bearer ${token}`) + .expect(200); + }); +}); diff --git a/node-api/src/__tests__/integration/rbac.test.js b/node-api/src/__tests__/integration/rbac.test.js new file mode 100644 index 0000000..067e944 --- /dev/null +++ b/node-api/src/__tests__/integration/rbac.test.js @@ -0,0 +1,123 @@ +const request = require('supertest'); +const { buildTestApp, teardownTestApp } = require('../helpers/testApp'); +const { seedInstitution, seedUser } = require('../helpers/seed'); + +describe('RBAC: self-service updates and institution-scoped approvals', () => { + let app; + let database; + let institutionRepository; + let userRepository; + let hashPassword; + + beforeAll(async () => { + ({ app, database, institutionRepository, userRepository, hashPassword } = await buildTestApp()); + }); + + afterAll(async () => { + await teardownTestApp(database); + }); + + async function login(email, password) { + const res = await request(app).post('/api/v1/auth/login').send({ email, password }).expect(200); + return res.body.data.accessToken; + } + + // Regression test for H-4: Subscription.tsx's "Upgrade to Pro" does + // PUT /users/:id (own id) with a `preferences` patch. This route used to + // be gated to super_admin/institution_admin only, 403ing every student. + it('lets a student self-update their own preferences (the upgrade flow) but not their role', async () => { + const institution = await seedInstitution(institutionRepository); + const { user, password } = await seedUser(userRepository, hashPassword, { + role: 'student', + institutionId: institution.id, + email: 'student-self@example.com', + }); + const token = await login(user.email, password); + + const res = await request(app) + .put(`/api/v1/users/${user.id}`) + .set('Authorization', `Bearer ${token}`) + .send({ preferences: { plan: 'pro' } }) + .expect(200); + expect(res.body.data.preferences.plan).toBe('pro'); + + // Privilege escalation attempt via the same self-service path must be + // silently dropped, not applied. + const escalate = await request(app) + .put(`/api/v1/users/${user.id}`) + .set('Authorization', `Bearer ${token}`) + .send({ role: 'super_admin', preferences: { plan: 'basic' } }) + .expect(200); + expect(escalate.body.data.role).toBe('student'); + expect(escalate.body.data.preferences.plan).toBe('basic'); + }); + + it('rejects a student updating a different user', async () => { + const institution = await seedInstitution(institutionRepository); + const { user: student, password } = await seedUser(userRepository, hashPassword, { + role: 'student', + institutionId: institution.id, + email: 'student-a@example.com', + }); + const { user: other } = await seedUser(userRepository, hashPassword, { + role: 'student', + institutionId: institution.id, + email: 'student-b@example.com', + }); + const token = await login(student.email, password); + + await request(app) + .put(`/api/v1/users/${other.id}`) + .set('Authorization', `Bearer ${token}`) + .send({ preferences: { plan: 'pro' } }) + .expect(403); + }); + + // Regression test for H-3: InstitutionalApproval.tsx (rendered on the + // institution-admin portal) calls PUT /users/:id/approve — this used to be + // super_admin-only, 403ing every institution admin trying to approve their + // own pending HR/faculty signups. + it('lets an institution_admin approve a pending user in their own institution', async () => { + const institution = await seedInstitution(institutionRepository); + const { user: admin, password: adminPassword } = await seedUser(userRepository, hashPassword, { + role: 'institution_admin', + institutionId: institution.id, + email: 'admin@example.com', + }); + const { user: pendingHr } = await seedUser(userRepository, hashPassword, { + role: 'hr', + institutionId: institution.id, + email: 'pending-hr@example.com', + status: 'pending', + }); + const token = await login(admin.email, adminPassword); + + const res = await request(app) + .put(`/api/v1/users/${pendingHr.id}/approve`) + .set('Authorization', `Bearer ${token}`) + .expect(200); + expect(res.body.data.status).toBe('approved'); + }); + + it('blocks an institution_admin from approving a user in a different institution', async () => { + const institutionA = await seedInstitution(institutionRepository, { code: `A-${Date.now()}` }); + const institutionB = await seedInstitution(institutionRepository, { code: `B-${Date.now()}` }); + const { user: adminA, password: adminPassword } = await seedUser(userRepository, hashPassword, { + role: 'institution_admin', + institutionId: institutionA.id, + email: 'admin-a@example.com', + }); + const { user: pendingInB } = await seedUser(userRepository, hashPassword, { + role: 'hr', + institutionId: institutionB.id, + email: 'pending-b@example.com', + status: 'pending', + }); + const token = await login(adminA.email, adminPassword); + + await request(app) + .put(`/api/v1/users/${pendingInB.id}/approve`) + .set('Authorization', `Bearer ${token}`) + .expect(403); + }); +}); diff --git a/node-api/src/__tests__/integration/resumeBuilder-staff-list.test.js b/node-api/src/__tests__/integration/resumeBuilder-staff-list.test.js new file mode 100644 index 0000000..4ca47a4 --- /dev/null +++ b/node-api/src/__tests__/integration/resumeBuilder-staff-list.test.js @@ -0,0 +1,94 @@ +const request = require('supertest'); +const { buildTestApp, teardownTestApp } = require('../helpers/testApp'); +const { seedInstitution, seedUser } = require('../helpers/seed'); + +// Regression test: GET /resume/all used the generic crudControllerFactory +// `list` handler, which calls service.list(req.query) with no second +// argument — but resumeBuilder.service.js#list requires `actor` to build its +// institution filter. Every staff call to this endpoint crashed with a +// TypeError reading institutionId off undefined. Caught while auditing every +// route built on the generic factory's list() after the Express 5 regression +// in department/collegeAdmin/faculty turned up the same missing-actor class +// of bug. Unrelated to Express 5 itself — the factory never passed req.user +// in any version. +describe('Resume Builder: staff listing (GET /resume/all)', () => { + let app; + let database; + let institutionRepository; + let userRepository; + let studentRepository; + let hashPassword; + + beforeAll(async () => { + ({ app, database, institutionRepository, userRepository, studentRepository, hashPassword } = await buildTestApp()); + }); + + afterAll(async () => { + await teardownTestApp(database); + }); + + async function login(email, password) { + const res = await request(app).post('/api/v1/auth/login').send({ email, password }).expect(200); + return res.body.data.accessToken; + } + + async function seedStudent(institutionId, overrides = {}) { + const { user, password } = await seedUser(userRepository, hashPassword, { + role: 'student', + institutionId, + email: `resume-student-${Date.now()}-${Math.random()}@example.com`, + ...overrides, + }); + await studentRepository.create({ + user_id: user.id, + institution_id: institutionId, + phone: '555-0100', + date_of_birth: '2000-01-01', + gender: 'female', + address: '123 Test St', + cgpa: 8.5, + }); + return { user, password }; + } + + it('does not crash and never leaks another institution\'s resumes', async () => { + const institutionA = await seedInstitution(institutionRepository, { code: `RA-${Date.now()}` }); + const institutionB = await seedInstitution(institutionRepository, { code: `RB-${Date.now()}` }); + + const { user: studentA, password: studentAPassword } = await seedStudent(institutionA.id); + const tokenStudentA = await login(studentA.email, studentAPassword); + // GET / auto-creates a default resume for the calling student. + await request(app).get('/api/v1/resume').set('Authorization', `Bearer ${tokenStudentA}`).expect(200); + + const { user: studentB, password: studentBPassword } = await seedStudent(institutionB.id); + const tokenStudentB = await login(studentB.email, studentBPassword); + await request(app).get('/api/v1/resume').set('Authorization', `Bearer ${tokenStudentB}`).expect(200); + + const { user: adminA, password: adminAPassword } = await seedUser(userRepository, hashPassword, { + role: 'institution_admin', + institutionId: institutionA.id, + email: `resume-admin-a-${Date.now()}@example.com`, + }); + const tokenAdminA = await login(adminA.email, adminAPassword); + + const res = await request(app).get('/api/v1/resume/all').set('Authorization', `Bearer ${tokenAdminA}`).expect(200); + const studentIds = res.body.data.map((r) => r.student_id); + expect(res.body.data.length).toBeGreaterThanOrEqual(1); + expect(studentIds.every((id) => id !== undefined)).toBe(true); + }); + + it('super_admin listing across every institution does not crash', async () => { + const institution = await seedInstitution(institutionRepository, { code: `RS-${Date.now()}` }); + const { user: student, password: studentPassword } = await seedStudent(institution.id); + const tokenStudent = await login(student.email, studentPassword); + await request(app).get('/api/v1/resume').set('Authorization', `Bearer ${tokenStudent}`).expect(200); + + const { user: superAdmin, password } = await seedUser(userRepository, hashPassword, { + role: 'super_admin', + email: `resume-super-${Date.now()}@example.com`, + }); + const superToken = await login(superAdmin.email, password); + + await request(app).get('/api/v1/resume/all').set('Authorization', `Bearer ${superToken}`).expect(200); + }); +}); diff --git a/node-api/src/__tests__/integration/studentIdentify.test.js b/node-api/src/__tests__/integration/studentIdentify.test.js new file mode 100644 index 0000000..8a91598 --- /dev/null +++ b/node-api/src/__tests__/integration/studentIdentify.test.js @@ -0,0 +1,94 @@ +const request = require('supertest'); +const { buildTestApp, teardownTestApp } = require('../helpers/testApp'); +const { seedInstitution, seedUser } = require('../helpers/seed'); + +// Regression test for a real bug found while auditing MongoDB indexes: +// student.service.js#identify (the unauthenticated kiosk check-in lookup) +// used to resolve the institution by name, then look up a student by +// roll_number ALONE — with no institution scoping — only rejecting a +// mismatch after the fact. Roll numbers are only unique *within* an +// institution (see the {institution_id, roll_number} unique index), so two +// colleges sharing a roll number could return the wrong student, and +// whichever one Mongo happened to return first could incorrectly fail a +// legitimate check-in. Fixed by scoping the lookup itself to +// {institution_id, roll_number} via findByInstitutionAndRollNumber. +describe('POST /students/identify: roll-number lookup is institution-scoped', () => { + let app; + let database; + let institutionRepository; + let userRepository; + let studentRepository; + let hashPassword; + + beforeAll(async () => { + ({ app, database, institutionRepository, userRepository, studentRepository, hashPassword } = await buildTestApp()); + }); + + afterAll(async () => { + await teardownTestApp(database); + }); + + async function seedStudentWithRollNumber(institutionId, rollNumber, fullName) { + const { user } = await seedUser(userRepository, hashPassword, { + role: 'student', + institutionId, + email: `identify-${Date.now()}-${Math.random()}@example.com`, + full_name: fullName, + }); + await studentRepository.create({ + user_id: user.id, + institution_id: institutionId, + roll_number: rollNumber, + phone: '555-0100', + date_of_birth: '2000-01-01', + gender: 'female', + address: '123 Test St', + cgpa: 8.5, + }); + return user; + } + + it('returns the correct student when two institutions share the same roll number', async () => { + const suffix = Date.now(); + const institutionA = await seedInstitution(institutionRepository, { + name: `Alpha Institute of Technology ${suffix}`, + code: `SIA-${suffix}`, + }); + const institutionB = await seedInstitution(institutionRepository, { + name: `Beta College of Engineering ${suffix}`, + code: `SIB-${suffix}`, + }); + + await seedStudentWithRollNumber(institutionA.id, 'CS101', 'Student From Alpha'); + await seedStudentWithRollNumber(institutionB.id, 'CS101', 'Student From Beta'); + + const res = await request(app) + .post('/api/v1/students/identify') + .send({ collegeName: `Beta College of Engineering ${suffix}`, rollNo: 'CS101' }) + .expect(200); + + expect(res.body.data.name).toBe('Student From Beta'); + expect(res.body.data.college).toBe(`Beta College of Engineering ${suffix}`); + }); + + it('returns 404 when the roll number exists only at a different institution', async () => { + const suffix = Date.now(); + const institutionA = await seedInstitution(institutionRepository, { + name: `Gamma University ${suffix}`, + code: `SIC-${suffix}`, + }); + // Seeded so its own existence (an unrelated institution) can't + // accidentally make the lookup succeed — never referenced by name. + await seedInstitution(institutionRepository, { + name: `Delta Polytechnic ${suffix}`, + code: `SID-${suffix}`, + }); + + await seedStudentWithRollNumber(institutionA.id, 'EE202', 'Only At Gamma'); + + await request(app) + .post('/api/v1/students/identify') + .send({ collegeName: `Delta Polytechnic ${suffix}`, rollNo: 'EE202' }) + .expect(404); + }); +}); diff --git a/node-api/src/__tests__/integration/students-directory.test.js b/node-api/src/__tests__/integration/students-directory.test.js new file mode 100644 index 0000000..765defa --- /dev/null +++ b/node-api/src/__tests__/integration/students-directory.test.js @@ -0,0 +1,111 @@ +const request = require('supertest'); +const { buildTestApp, teardownTestApp } = require('../helpers/testApp'); +const { seedInstitution, seedUser } = require('../helpers/seed'); + +// Regression tests for H-6: GET /students has no role gate (PlatformChat.tsx +// calls it as a student when GET /users/ 403s them), and used to return every +// raw student field — phone, date_of_birth, gender, address, cgpa — to +// whichever role asked. Non-staff callers should get a minimal directory +// projection instead; staff should still see full records within their own +// institution and never another institution's. +describe('Students: PII-minimized directory for non-staff, full detail for staff', () => { + let app; + let database; + let institutionRepository; + let userRepository; + let studentRepository; + let hashPassword; + + beforeAll(async () => { + ({ app, database, institutionRepository, userRepository, studentRepository, hashPassword } = await buildTestApp()); + }); + + afterAll(async () => { + await teardownTestApp(database); + }); + + async function login(email, password) { + const res = await request(app).post('/api/v1/auth/login').send({ email, password }).expect(200); + return res.body.data.accessToken; + } + + async function seedStudent(institutionId, overrides = {}) { + const { user, password } = await seedUser(userRepository, hashPassword, { + role: 'student', + institutionId, + email: `student-${Date.now()}-${Math.random()}@example.com`, + phone: '555-0100', + ...overrides, + }); + await studentRepository.create({ + user_id: user.id, + institution_id: institutionId, + phone: '555-0100', + date_of_birth: '2000-01-01', + gender: 'female', + address: '123 Secret St', + cgpa: 8.9, + }); + return { user, password }; + } + + it('hides sensitive fields from a student caller but keeps id/name/email', async () => { + const institution = await seedInstitution(institutionRepository); + const { user: viewer, password } = await seedStudent(institution.id); + await seedStudent(institution.id); // a classmate, visible in the directory + + const token = await login(viewer.email, password); + const res = await request(app).get('/api/v1/students').set('Authorization', `Bearer ${token}`).expect(200); + + expect(res.body.data.length).toBeGreaterThanOrEqual(1); + for (const entry of res.body.data) { + expect(entry).not.toHaveProperty('phone'); + expect(entry).not.toHaveProperty('date_of_birth'); + expect(entry).not.toHaveProperty('gender'); + expect(entry).not.toHaveProperty('address'); + expect(entry).not.toHaveProperty('cgpa'); + expect(entry).toHaveProperty('id'); + expect(entry).toHaveProperty('full_name'); + } + }); + + it('gives staff (institution_admin) the full record within their own institution', async () => { + const institution = await seedInstitution(institutionRepository); + await seedStudent(institution.id); + const { user: admin, password: adminPassword } = await seedUser(userRepository, hashPassword, { + role: 'institution_admin', + institutionId: institution.id, + email: `admin-${Date.now()}@example.com`, + }); + const token = await login(admin.email, adminPassword); + + const res = await request(app) + .get('/api/v1/students') + .query({ search: 'student' }) + .set('Authorization', `Bearer ${token}`) + .expect(200); + + expect(res.body.data.length).toBeGreaterThanOrEqual(1); + expect(res.body.data[0]).toHaveProperty('date_of_birth'); + }); + + it('never leaks a student from a different institution into another institution_admin\'s search', async () => { + const institutionA = await seedInstitution(institutionRepository, { code: `SA-${Date.now()}` }); + const institutionB = await seedInstitution(institutionRepository, { code: `SB-${Date.now()}` }); + await seedStudent(institutionB.id, { full_name: 'Cross Tenant Student' }); + const { user: adminA, password: adminPassword } = await seedUser(userRepository, hashPassword, { + role: 'institution_admin', + institutionId: institutionA.id, + email: `admin-a-${Date.now()}@example.com`, + }); + const token = await login(adminA.email, adminPassword); + + const res = await request(app) + .get('/api/v1/students') + .query({ search: 'Cross Tenant' }) + .set('Authorization', `Bearer ${token}`) + .expect(200); + + expect(res.body.data.length).toBe(0); + }); +}); diff --git a/node-api/src/__tests__/integration/uploads.test.js b/node-api/src/__tests__/integration/uploads.test.js new file mode 100644 index 0000000..ca8b785 --- /dev/null +++ b/node-api/src/__tests__/integration/uploads.test.js @@ -0,0 +1,92 @@ +const request = require('supertest'); +const { buildTestApp, teardownTestApp } = require('../helpers/testApp'); +const { seedInstitution, seedUser } = require('../helpers/seed'); + +// Regression tests for H-7: profile.routes.js's upload.any() used to accept +// any file with no MIME/extension/content check at all. +describe('Upload security: /profile', () => { + let app; + let database; + let institutionRepository; + let userRepository; + let hashPassword; + + beforeAll(async () => { + ({ app, database, institutionRepository, userRepository, hashPassword } = await buildTestApp()); + }); + + afterAll(async () => { + await teardownTestApp(database); + }); + + // /profile is the staff onboarding endpoint (hr/institution_admin/faculty + // — see onboardingSchemas.js#ROLE_TO_PORTAL); students use a separate, + // non-multipart /students/profile endpoint. hr is used here purely as a + // role that's allowed to reach this route at all. + async function loginAsHr() { + const institution = await seedInstitution(institutionRepository); + const { user, password } = await seedUser(userRepository, hashPassword, { + role: 'hr', + institutionId: institution.id, + email: `upload-${Date.now()}@example.com`, + }); + const res = await request(app).post('/api/v1/auth/login').send({ email: user.email, password }).expect(200); + return res.body.data.accessToken; + } + + // A real 1x1 PNG's magic bytes. + const REAL_PNG = Buffer.from('89504e470d0a1a0a0000000d49484452', 'hex'); + + it('rejects a file whose extension/mimetype is not on the image allow-list', async () => { + const token = await loginAsHr(); + await request(app) + .post('/api/v1/profile') + .set('Authorization', `Bearer ${token}`) + .attach('profilePhoto', Buffer.from('#!/bin/sh\necho pwned'), { filename: 'shell.sh', contentType: 'application/x-sh' }) + .expect(400); + }); + + it('rejects a file with a disguised double extension claiming to be an image', async () => { + const token = await loginAsHr(); + await request(app) + .post('/api/v1/profile') + .set('Authorization', `Bearer ${token}`) + .attach('profilePhoto', Buffer.from(''), { + filename: 'avatar.jpg.php', + contentType: 'image/jpeg', + }) + .expect(400); + }); + + it('rejects content whose magic bytes do not match its claimed image MIME type', async () => { + const token = await loginAsHr(); + // Declares image/png + a .png filename, but the actual bytes are plain + // text — this is exactly what a renamed executable/script looks like to + // a check that only trusts the extension or Content-Type header. + await request(app) + .post('/api/v1/profile') + .set('Authorization', `Bearer ${token}`) + .attach('profilePhoto', Buffer.from('not actually a png'), { filename: 'fake.png', contentType: 'image/png' }) + .expect(400); + }); + + it('accepts a real PNG', async () => { + const token = await loginAsHr(); + const res = await request(app) + .post('/api/v1/profile') + .set('Authorization', `Bearer ${token}`) + .attach('profilePhoto', REAL_PNG, { filename: 'avatar.png', contentType: 'image/png' }) + .expect(200); + expect(res.body.data.values.profilePhoto).toMatch(/^\/uploads\/profile\/.+\.png$/); + }); + + it('rejects an oversized file', async () => { + const token = await loginAsHr(); + const oversized = Buffer.concat([REAL_PNG, Buffer.alloc(6 * 1024 * 1024)]); + await request(app) + .post('/api/v1/profile') + .set('Authorization', `Bearer ${token}`) + .attach('profilePhoto', oversized, { filename: 'huge.png', contentType: 'image/png' }) + .expect(400); + }); +}); diff --git a/node-api/src/app.js b/node-api/src/app.js index 3c40925..d62c52a 100644 --- a/node-api/src/app.js +++ b/node-api/src/app.js @@ -7,8 +7,11 @@ const morgan = require('morgan'); const env = require('./config/env'); const routes = require('./routes'); +const healthRoutes = require('./routes/health.routes'); const { apiLimiter } = require('./middlewares/rateLimiter'); const { notFoundHandler, errorHandler } = require('./middlewares/errorHandler'); +const requestId = require('./middlewares/requestId'); +const requestLogger = require('./middlewares/requestLogger'); const logger = require('./utils/logger'); const app = express(); @@ -17,6 +20,8 @@ const app = express(); // see the real client IP instead of the proxy's. app.set('trust proxy', 1); +app.use(requestId); +app.use(requestLogger); app.use(helmet()); app.use( cors({ @@ -34,9 +39,18 @@ app.use(compression()); app.use(express.json({ limit: '1mb' })); app.use(express.urlencoded({ extended: true, limit: '1mb' })); app.use(morgan(env.isProduction ? 'combined' : 'dev', { stream: { write: (msg) => logger.info(msg.trim()) } })); -app.use(apiLimiter); +// Registered *before* apiLimiter, deliberately: these are infrastructure +// probes (load balancer / orchestrator health checks), not attacker-facing +// API surface, and polling them every few seconds from a shared LB IP would +// otherwise burn through the same rate-limit budget as real traffic — +// caught by actually load-testing /health, which started returning 429 +// under sustained request volume well below what a real liveness probe +// schedule would produce across several replicas. app.get('/health', (req, res) => res.status(200).json({ status: 'ok', uptime: process.uptime() })); +app.use('/health', healthRoutes); // adds /health/live (alias), /health/ready, /health/metrics + +app.use(apiLimiter); // Serves uploaded profile photos/signatures (see src/middlewares/upload.js). // Matches python-service's public /uploads/profile/ URL shape. diff --git a/node-api/src/config/database.js b/node-api/src/config/database.js index 31f4378..8579f26 100644 --- a/node-api/src/config/database.js +++ b/node-api/src/config/database.js @@ -52,6 +52,15 @@ function getCollection(name) { return getDb().collection(name); } +// Used by the /health/ready check (routes/health.routes.js) — a real +// round-trip to Mongo, not just "is the client object connected", so +// readiness reflects whether Mongo can actually answer right now. +async function ping() { + const start = process.hrtime.bigint(); + await getDb().command({ ping: 1 }); + return Number(process.hrtime.bigint() - start) / 1e6; // ms +} + async function withTransaction(fn) { const session = client.startSession(); try { @@ -71,4 +80,4 @@ async function close() { connectPromise = null; } -module.exports = { connect, getDb, getCollection, withTransaction, close, client }; +module.exports = { connect, getDb, getCollection, withTransaction, close, ping, client }; diff --git a/node-api/src/config/env.js b/node-api/src/config/env.js index 1dc88e1..ceff9cb 100644 --- a/node-api/src/config/env.js +++ b/node-api/src/config/env.js @@ -38,11 +38,13 @@ module.exports = { clientSecret: process.env.GOOGLE_CLIENT_SECRET, }, - // Optional: routes that call Groq must check isGroqConfigured() (see - // src/utils/groqClient.js) and degrade gracefully rather than crash — not - // required at startup because not every deployment needs AI features. - groq: { - apiKey: process.env.GROQ_API_KEY || '', + // The FastAPI AI microservice (interview generation, resume AI features). + // Not required at startup — utils/aiServiceClient.js degrades gracefully + // (local fallback) when unset or unreachable, same philosophy as groq above. + aiService: { + url: process.env.AI_SERVICE_URL || 'http://localhost:8001', + sharedSecret: process.env.AI_SERVICE_SHARED_SECRET || '', + timeoutMs: parseInt(process.env.AI_SERVICE_TIMEOUT_MS, 10) || 15000, }, // Optional, same reasoning as groq above — see email.service.js#isEmailConfigured. diff --git a/node-api/src/config/redis.js b/node-api/src/config/redis.js new file mode 100644 index 0000000..88be527 --- /dev/null +++ b/node-api/src/config/redis.js @@ -0,0 +1,64 @@ +// Shared ioredis client, used by the rate limiter store +// (middlewares/rateLimitStore.js). The WebSocket broadcaster +// (websocket/redisBroadcaster.js) uses its own dedicated pub/sub clients, +// because Redis subscribe-mode connections cannot be reused for publish/other +// commands. +// +// Entirely optional: with REDIS_URL unset, getRedisClient() returns null and +// every caller falls back to its single-instance-only behavior (in-memory +// rate limiting, in-process-only WS broadcast — both already correct for a +// single node-api instance, which is today's real deployment). Nothing in +// this app requires Redis to run. +const Redis = require('ioredis'); +const logger = require('../utils/logger'); + +let client = null; +let attempted = false; + +function getRedisClient() { + if (client) return client; + if (attempted) return null; // already tried and REDIS_URL was unset — don't recheck every call + attempted = true; + + const url = process.env.REDIS_URL; + if (!url) return null; + + client = new Redis(url, { + // Per-command timeout via limited retries — a rate-limit check or a chat + // broadcast should never hang a request waiting on a wedged Redis; the + // caller's own fallback (HybridRateLimitStore, in-memory broadcast) + // takes over instead. Reconnection in the background is unaffected — + // this only bounds how long any single command will keep retrying. + maxRetriesPerRequest: 1, + // Exponential-ish backoff, capped — avoids a reconnect storm hammering + // Redis the moment it comes back up after an outage. + retryStrategy: (attempts) => Math.min(attempts * 200, 5000), + lazyConnect: false, + }); + + client.on('connect', () => logger.info('Redis: connected')); + client.on('ready', () => logger.info('Redis: ready')); + client.on('error', (err) => logger.warn('Redis: connection error', { error: err.message })); + client.on('close', () => logger.warn('Redis: connection closed')); + client.on('reconnecting', (delay) => logger.info('Redis: reconnecting', { delayMs: delay })); + client.on('end', () => logger.warn('Redis: connection ended (giving up)')); + + return client; +} + +// Checked on every call site rather than cached — reflects Redis's *current* +// reachability so a call made mid-outage (or mid-recovery) gets the right +// answer without needing its own reconnect-tracking logic. +function isRedisReady() { + return client !== null && client.status === 'ready'; +} + +async function closeRedisClient() { + if (client) { + await client.quit().catch(() => client.disconnect()); + client = null; + attempted = false; + } +} + +module.exports = { getRedisClient, isRedisReady, closeRedisClient }; diff --git a/node-api/src/controllers/auth.controller.js b/node-api/src/controllers/auth.controller.js index f77a11b..b6ae6c7 100644 --- a/node-api/src/controllers/auth.controller.js +++ b/node-api/src/controllers/auth.controller.js @@ -53,8 +53,16 @@ const googleLogin = asyncHandler(async (req, res) => { }); const refresh = asyncHandler(async (req, res) => { - const result = await authService.refresh(req.body.refreshToken); - ApiResponse.ok(res, withLegacyAuthFields(result), 'Token refreshed'); + const token = req.body.refreshToken || req.body.refresh_token; + const result = await authService.refresh(token); + const payload = withLegacyAuthFields(result); + // /auth/refresh is called from a bare axios instance that deliberately + // skips the {success,data} envelope-unwrap interceptor every other call + // site relies on (see api.ts — avoids recursing back into itself on 401). + // It reads access_token/refresh_token straight off the response body, so + // duplicate them at the top level here while keeping the normal envelope + // for every other consumer. + res.status(200).json({ success: true, message: 'Token refreshed', data: payload, ...payload }); }); const me = asyncHandler(async (req, res) => { @@ -79,4 +87,27 @@ const verifyEmail = asyncHandler(async (req, res) => { ApiResponse.ok(res, null, 'Email verified successfully.'); }); -module.exports = { register, login, googleLogin, refresh, me, forgotPassword, resetPassword, changeInitialPassword, verifyEmail }; +// Live frontend sends snake_case (current_password/new_password) — see +// validations/auth.validation.js#changePassword. +const changePassword = asyncHandler(async (req, res) => { + const currentPassword = req.body.currentPassword || req.body.current_password; + const newPassword = req.body.newPassword || req.body.new_password; + const result = await authService.changePassword(req.user.id, currentPassword, newPassword); + // Password change invalidates every other refresh token (see + // authService.js#changePassword) — a fresh pair is returned here so the + // caller's own session can keep going without a forced re-login. + ApiResponse.ok(res, withLegacyAuthFields(result), 'Password changed successfully'); +}); + +module.exports = { + register, + login, + googleLogin, + refresh, + me, + forgotPassword, + resetPassword, + changeInitialPassword, + changePassword, + verifyEmail, +}; diff --git a/node-api/src/controllers/collegeAdmin.controller.js b/node-api/src/controllers/collegeAdmin.controller.js index 0d093ec..be06b81 100644 --- a/node-api/src/controllers/collegeAdmin.controller.js +++ b/node-api/src/controllers/collegeAdmin.controller.js @@ -1,4 +1,16 @@ const createCrudController = require('./crudControllerFactory'); const collegeAdminService = require('../services/collegeAdmin.service'); +const asyncHandler = require('../utils/asyncHandler'); +const ApiResponse = require('../utils/ApiResponse'); -module.exports = createCrudController(collegeAdminService); +const base = createCrudController(collegeAdminService); + +// Overrides the generic base `list` — collegeAdminService.list() needs +// `actor` for institution scoping, which crudControllerFactory's generic +// list handler doesn't pass. +const list = asyncHandler(async (req, res) => { + const { rows, meta } = await collegeAdminService.list(req.query, req.user); + ApiResponse.paginated(res, rows, meta); +}); + +module.exports = { ...base, list }; diff --git a/node-api/src/controllers/dashboard.controller.js b/node-api/src/controllers/dashboard.controller.js index 5013bcb..4d855d1 100644 --- a/node-api/src/controllers/dashboard.controller.js +++ b/node-api/src/controllers/dashboard.controller.js @@ -4,7 +4,9 @@ const ApiResponse = require('../utils/ApiResponse'); const student = asyncHandler(async (req, res) => { const result = await dashboardService.studentDashboard(req.params.studentId); - ApiResponse.ok(res, result); + // See ApiResponse.okDoubleWrapped — StudentTracking.tsx's "insights" panel + // reads res.data.success/res.data.data off the already-unwrapped response. + ApiResponse.okDoubleWrapped(res, result); }); const admin = asyncHandler(async (req, res) => { diff --git a/node-api/src/controllers/department.controller.js b/node-api/src/controllers/department.controller.js index 733c6ac..03b37e3 100644 --- a/node-api/src/controllers/department.controller.js +++ b/node-api/src/controllers/department.controller.js @@ -5,9 +5,17 @@ const ApiResponse = require('../utils/ApiResponse'); const base = createCrudController(departmentService); +// Overrides the generic base `list` — departmentService.list() needs `actor` +// for institution scoping (see that file), which crudControllerFactory's +// generic list handler doesn't pass. +const list = asyncHandler(async (req, res) => { + const { rows, meta } = await departmentService.list(req.query, req.user); + ApiResponse.paginated(res, rows, meta); +}); + const listByInstitution = asyncHandler(async (req, res) => { const rows = await departmentService.listByInstitution(req.params.id); ApiResponse.ok(res, rows); }); -module.exports = { ...base, listByInstitution }; +module.exports = { ...base, list, listByInstitution }; diff --git a/node-api/src/controllers/faculty.controller.js b/node-api/src/controllers/faculty.controller.js index 5a4f80c..f0e2c17 100644 --- a/node-api/src/controllers/faculty.controller.js +++ b/node-api/src/controllers/faculty.controller.js @@ -1,4 +1,16 @@ const createCrudController = require('./crudControllerFactory'); const facultyService = require('../services/faculty.service'); +const asyncHandler = require('../utils/asyncHandler'); +const ApiResponse = require('../utils/ApiResponse'); -module.exports = createCrudController(facultyService); +const base = createCrudController(facultyService); + +// Overrides the generic base `list` — facultyService.list() needs `actor` +// for institution scoping, which crudControllerFactory's generic list +// handler doesn't pass. +const list = asyncHandler(async (req, res) => { + const { rows, meta } = await facultyService.list(req.query, req.user); + ApiResponse.paginated(res, rows, meta); +}); + +module.exports = { ...base, list }; diff --git a/node-api/src/controllers/leaderboard.controller.js b/node-api/src/controllers/leaderboard.controller.js index 264f1fe..e41be16 100644 --- a/node-api/src/controllers/leaderboard.controller.js +++ b/node-api/src/controllers/leaderboard.controller.js @@ -4,7 +4,7 @@ const ApiResponse = require('../utils/ApiResponse'); const get = asyncHandler(async (req, res) => { const rows = await achievementService.leaderboard(req.user, req.query.scope); - ApiResponse.ok(res, rows); + ApiResponse.okDoubleWrapped(res, rows); }); module.exports = { get }; diff --git a/node-api/src/controllers/placement.controller.js b/node-api/src/controllers/placement.controller.js index d30d3fc..2dff4d5 100644 --- a/node-api/src/controllers/placement.controller.js +++ b/node-api/src/controllers/placement.controller.js @@ -10,13 +10,19 @@ const create = asyncHandler(async (req, res) => { ApiResponse.created(res, placement); }); +// The frontend consumes both of these as a plain array (no pagination UI — +// see hr/page.tsx), so the response body stays an array; the true total is +// still surfaced via X-Total-Count for any caller that cares whether `rows` +// was truncated (see placement.service.js#listMine/#listDrives). const listMine = asyncHandler(async (req, res) => { - const rows = await placementService.listMine(req.user); + const { rows, total } = await placementService.listMine(req.user); + res.set('X-Total-Count', String(total)); ApiResponse.ok(res, rows); }); const listDrives = asyncHandler(async (req, res) => { - const rows = await placementService.listDrives(req.user); + const { rows, total } = await placementService.listDrives(req.user); + res.set('X-Total-Count', String(total)); ApiResponse.ok(res, rows); }); diff --git a/node-api/src/controllers/placementApplication.controller.js b/node-api/src/controllers/placementApplication.controller.js index 057f0dd..4123b49 100644 --- a/node-api/src/controllers/placementApplication.controller.js +++ b/node-api/src/controllers/placementApplication.controller.js @@ -22,4 +22,9 @@ const updateStatus = asyncHandler(async (req, res) => { ApiResponse.ok(res, application, 'Status updated'); }); -module.exports = { list, getById, create, updateStatus }; +const bulkShortlist = asyncHandler(async (req, res) => { + const result = await placementApplicationService.bulkShortlist(req.body, req.user); + ApiResponse.ok(res, result, 'Candidates shortlisted'); +}); + +module.exports = { list, getById, create, updateStatus, bulkShortlist }; diff --git a/node-api/src/controllers/placementRecord.controller.js b/node-api/src/controllers/placementRecord.controller.js index abb0f8b..02e5fb1 100644 --- a/node-api/src/controllers/placementRecord.controller.js +++ b/node-api/src/controllers/placementRecord.controller.js @@ -11,7 +11,8 @@ const list = asyncHandler(async (req, res) => { }); const create = asyncHandler(async (req, res) => { - const record = await placementRecordService.create(req.body, req.user); + const proofFile = (req.files || []).find((f) => f.fieldname === 'proof_file'); + const record = await placementRecordService.create(req.body, req.user, proofFile); ApiResponse.created(res, record); }); diff --git a/node-api/src/controllers/resumeBuilder.controller.js b/node-api/src/controllers/resumeBuilder.controller.js index f76fff5..d8c0223 100644 --- a/node-api/src/controllers/resumeBuilder.controller.js +++ b/node-api/src/controllers/resumeBuilder.controller.js @@ -5,6 +5,17 @@ const ApiResponse = require('../utils/ApiResponse'); const base = createCrudController(resumeBuilderService); +// Overrides the generic base `list` — resumeBuilderService.list() needs +// `actor` for institution scoping (buildInstitutionFilter), which +// crudControllerFactory's generic list handler never passes. Without this, +// GET /resume/all crashed with a TypeError reading actor.institutionId off +// undefined — a pre-existing bug, unrelated to the Express 5 migration, +// caught while auditing every route using the generic factory's list(). +const list = asyncHandler(async (req, res) => { + const { rows, meta } = await resumeBuilderService.list(req.query, req.user); + ApiResponse.paginated(res, rows, meta); +}); + const getOwn = asyncHandler(async (req, res) => { const result = await resumeBuilderService.getOwn(req.user); ApiResponse.ok(res, result); @@ -52,6 +63,7 @@ const parseResumeText = asyncHandler(async (req, res) => { module.exports = { ...base, + list, getOwn, saveOwn, addVersion, diff --git a/node-api/src/controllers/user.controller.js b/node-api/src/controllers/user.controller.js index 1e0a0a6..d0fc955 100644 --- a/node-api/src/controllers/user.controller.js +++ b/node-api/src/controllers/user.controller.js @@ -33,12 +33,12 @@ const remove = asyncHandler(async (req, res) => { }); const approve = asyncHandler(async (req, res) => { - const user = await userService.approve(req.params.id); + const user = await userService.approve(req.params.id, req.user); ApiResponse.ok(res, user, 'Approved'); }); const reject = asyncHandler(async (req, res) => { - const user = await userService.reject(req.params.id); + const user = await userService.reject(req.params.id, req.user); ApiResponse.ok(res, user, 'Rejected'); }); diff --git a/node-api/src/middlewares/authenticate.js b/node-api/src/middlewares/authenticate.js index 8f6f7b4..b947430 100644 --- a/node-api/src/middlewares/authenticate.js +++ b/node-api/src/middlewares/authenticate.js @@ -1,4 +1,5 @@ const { verifyAccessToken } = require('../utils/jwt'); +const { mapPythonRole } = require('../config/roleMapping'); const ApiError = require('../utils/ApiError'); const asyncHandler = require('../utils/asyncHandler'); @@ -15,9 +16,18 @@ module.exports = asyncHandler(async (req, res, next) => { try { const payload = verifyAccessToken(token); - req.user = { id: payload.sub, role: payload.role, institutionId: payload.institutionId }; + // mapPythonRole normalizes any legacy python-service role string (e.g. + // `college_admin`) still baked into an already-issued token — issueTokens + // now normalizes on every fresh sign, but this covers sessions signed + // before that fix, or before a DB row's role gets backfilled, so + // authorize()'s role check (ROLES.INSTITUTION_ADMIN etc.) always sees + // the current role vocabulary regardless of what the token literally says. + req.user = { id: payload.sub, role: mapPythonRole(payload.role), institutionId: payload.institutionId }; next(); - } catch (err) { + } catch { + // Deliberately generic — never echo back jwt.verify's own error message + // (expired vs malformed vs bad signature), which would hand an attacker + // a free oracle for probing token validity. throw ApiError.unauthorized('Invalid or expired access token'); } }); diff --git a/node-api/src/middlewares/errorHandler.js b/node-api/src/middlewares/errorHandler.js index 5085c0f..0c8e851 100644 --- a/node-api/src/middlewares/errorHandler.js +++ b/node-api/src/middlewares/errorHandler.js @@ -9,11 +9,26 @@ function notFoundHandler(req, res, next) { // Centralized error handler. MongoDB driver errors are translated into safe, generic // HTTP responses so raw driver details never leak to API clients. function errorHandler(err, req, res, next) { - let { statusCode = 500, message } = err; + let { statusCode = 500, message, code } = err; if (err.code === 11000) { // duplicate key (unique index violation) statusCode = 409; message = 'A record with these details already exists'; + code = 'DUPLICATE_RECORD'; + } else if (err.name === 'MulterError') { + // multer throws its own error class (not ApiError) for upload-limit + // violations — LIMIT_FILE_SIZE (upload.js's 5MB cap), too many files, + // unexpected field name, etc. All of these are the client's fault, not + // a server fault, so this maps them to 400 instead of falling through + // to the generic 500 below. + statusCode = 400; + if (err.code === 'LIMIT_FILE_SIZE') { + message = 'File exceeds the maximum upload size'; + code = 'FILE_TOO_LARGE'; + } else { + message = err.message; + code = 'UPLOAD_ERROR'; + } } else if ( err.name === 'MongoServerSelectionError' || err.name === 'MongoNetworkError' || @@ -21,11 +36,17 @@ function errorHandler(err, req, res, next) { ) { statusCode = 503; message = 'Database is unreachable'; + code = 'SERVICE_UNAVAILABLE'; } else if (!(err instanceof ApiError)) { // Some Node errors (e.g. AggregateError from a failed connection attempt) have an // empty top-level .message — fall back to a generic one instead of surfacing "". message = env.isProduction ? 'Internal server error' : message || 'Internal server error'; + code = 'INTERNAL_ERROR'; } + // Belt-and-suspenders: any status that reached here without a code (e.g. a + // future branch above that forgets to set one) still gets a stable + // fallback derived from the status, rather than `code: undefined`. + code = code || ApiError.codeForStatus(statusCode); if (statusCode >= 500) { logger.error(err.message || message, { stack: err.stack, path: req.originalUrl }); @@ -36,6 +57,17 @@ function errorHandler(err, req, res, next) { res.status(statusCode).json({ success: false, message, + // `detail` duplicates `message` for the many frontend call sites still + // reading FastAPI's HTTPException shape (err.response.data.detail) from + // the python-service days — without it every one of those falls back to + // a generic client-side string instead of this response's real message. + detail: message, + // Stable, machine-readable — see ApiError.js. `message`/`detail` are for + // display and are free to reword; `code` is what frontend logic should + // branch on (e.g. redirecting to login only on 'UNAUTHORIZED', not on + // every 401-shaped string). + code, + timestamp: new Date().toISOString(), ...(err.details ? { details: err.details } : {}), }); } diff --git a/node-api/src/middlewares/rateLimitStore.js b/node-api/src/middlewares/rateLimitStore.js new file mode 100644 index 0000000..6f17e92 --- /dev/null +++ b/node-api/src/middlewares/rateLimitStore.js @@ -0,0 +1,89 @@ +const { getRedisClient, isRedisReady } = require('../config/redis'); +const logger = require('../utils/logger'); + +// express-rate-limit Store implementation (see that package's `Store` type) +// backed by Redis when reachable, an in-process Map otherwise — checked on +// every increment, not just at startup, so this self-heals the moment Redis +// reconnects without needing its own health-polling loop. +// +// Falling back to per-instance in-memory counting during a Redis outage +// means limits are enforced per-instance instead of globally for as long as +// the outage lasts — looser than the intended global limit, but still real +// protection, and far better than either failing the request outright or +// (the actual dangerous failure mode) silently disabling rate limiting +// entirely until someone notices. +class HybridRateLimitStore { + constructor({ prefix, windowMs }) { + this.prefix = prefix; + this.windowMs = windowMs; + // Redis-backed counts are shared across every node-api instance; the + // in-memory fallback is not — express-rate-limit's own double-counting + // guard only cares whether *this* value is stable, so leave it at its + // conservative (Redis-shaped) setting rather than flip-flopping it. + this.localKeys = false; + this.memory = new Map(); // key -> { count, resetTime: epoch ms } + } + + _memoryIncrement(key) { + const now = Date.now(); + const existing = this.memory.get(key); + if (!existing || existing.resetTime <= now) { + const resetTime = now + this.windowMs; + this.memory.set(key, { count: 1, resetTime }); + return { totalHits: 1, resetTime: new Date(resetTime) }; + } + existing.count += 1; + return { totalHits: existing.count, resetTime: new Date(existing.resetTime) }; + } + + async increment(key) { + const client = getRedisClient(); + if (!client || !isRedisReady()) { + return this._memoryIncrement(key); + } + try { + const redisKey = this.prefix + key; + const count = await client.incr(redisKey); + let ttl = await client.pttl(redisKey); + if (ttl < 0) { + // First hit in this window (or the key somehow lost its TTL) — set one. + await client.pexpire(redisKey, this.windowMs); + ttl = this.windowMs; + } + return { totalHits: count, resetTime: new Date(Date.now() + ttl) }; + } catch (err) { + logger.warn('Redis rate-limit increment failed, using in-memory fallback for this request', { + error: err.message, + }); + return this._memoryIncrement(key); + } + } + + async decrement(key) { + const client = getRedisClient(); + if (client && isRedisReady()) { + try { + await client.decr(this.prefix + key); + return; + } catch (err) { + logger.warn('Redis rate-limit decrement failed', { error: err.message }); + } + } + const existing = this.memory.get(key); + if (existing) existing.count = Math.max(0, existing.count - 1); + } + + async resetKey(key) { + const client = getRedisClient(); + if (client && isRedisReady()) { + try { + await client.del(this.prefix + key); + } catch (err) { + logger.warn('Redis rate-limit resetKey failed', { error: err.message }); + } + } + this.memory.delete(key); + } +} + +module.exports = { HybridRateLimitStore }; diff --git a/node-api/src/middlewares/rateLimiter.js b/node-api/src/middlewares/rateLimiter.js index 6455e93..2d8af96 100644 --- a/node-api/src/middlewares/rateLimiter.js +++ b/node-api/src/middlewares/rateLimiter.js @@ -1,5 +1,16 @@ const rateLimit = require('express-rate-limit'); const env = require('../config/env'); +const { HybridRateLimitStore } = require('./rateLimitStore'); + +// Redis-backed when REDIS_URL is set (shared limits across every node-api +// instance — required once this runs behind a load balancer with more than +// one replica), falls back to the same in-memory behavior as before when it +// isn't. Store swap only — the exported limiters below are otherwise +// unchanged, so every route.use(apiLimiter)/authLimiter/identifyLimiter call +// site needs no changes at all. +function storeFor(name, windowMs) { + return new HybridRateLimitStore({ prefix: `rl:${name}:`, windowMs }); +} // General API limiter, applied globally. const apiLimiter = rateLimit({ @@ -8,26 +19,36 @@ const apiLimiter = rateLimit({ standardHeaders: true, legacyHeaders: false, message: { success: false, message: 'Too many requests, please try again later.' }, + store: storeFor('api', env.rateLimit.windowMs), }); -// Stricter limiter for login/register/OAuth to blunt credential-stuffing and brute force. +// Stricter limiter for login/register/OAuth to blunt credential-stuffing and +// brute force. Configurable the same way apiLimiter is above — defaults to +// the same 20/15min in every real deployment; only overridden in the +// integration test suite (see __tests__/helpers/testApp.js), where dozens of +// auth calls in one file would otherwise trip it well before any real +// brute-force threshold is relevant. +const AUTH_WINDOW_MS = 15 * 60 * 1000; const authLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, - max: 20, + windowMs: AUTH_WINDOW_MS, + max: parseInt(process.env.AUTH_RATE_LIMIT_MAX, 10) || 20, standardHeaders: true, legacyHeaders: false, message: { success: false, message: 'Too many authentication attempts, please try again later.' }, + store: storeFor('auth', AUTH_WINDOW_MS), }); // Public, unauthenticated kiosk lookup (see student.routes.js#/identify) — // tight limit since it's a no-login endpoint that resolves a college+roll // pair to a real student record. +const IDENTIFY_WINDOW_MS = 15 * 60 * 1000; const identifyLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, - max: 10, + windowMs: IDENTIFY_WINDOW_MS, + max: parseInt(process.env.IDENTIFY_RATE_LIMIT_MAX, 10) || 10, standardHeaders: true, legacyHeaders: false, message: { success: false, message: 'Too many lookup attempts, please try again later.' }, + store: storeFor('identify', IDENTIFY_WINDOW_MS), }); module.exports = { apiLimiter, authLimiter, identifyLimiter }; diff --git a/node-api/src/middlewares/requestId.js b/node-api/src/middlewares/requestId.js new file mode 100644 index 0000000..4ead27e --- /dev/null +++ b/node-api/src/middlewares/requestId.js @@ -0,0 +1,14 @@ +const crypto = require('crypto'); + +// Correlation ID: accepts an inbound `X-Request-Id` (set by a load balancer, +// or by the frontend/another service calling in) so a request can be traced +// across service boundaries, and generates one when absent (a direct client +// call, or a load balancer that doesn't set one). Echoed back on the +// response so a caller who didn't send one still gets something to quote +// back when reporting an issue. +module.exports = function requestId(req, res, next) { + const incoming = req.headers['x-request-id']; + req.id = (typeof incoming === 'string' && incoming.trim()) || crypto.randomUUID(); + res.set('X-Request-Id', req.id); + next(); +}; diff --git a/node-api/src/middlewares/requestLogger.js b/node-api/src/middlewares/requestLogger.js new file mode 100644 index 0000000..cb89085 --- /dev/null +++ b/node-api/src/middlewares/requestLogger.js @@ -0,0 +1,33 @@ +const logger = require('../utils/logger'); + +// One structured JSON log line per completed request — method, path, status, +// latency, request id, and (when authenticated) the actor. Distinct from +// morgan (app.js) which stays for human-readable console access logs in +// dev; this is the machine-parseable counterpart a log aggregator (or the +// latency-percentile calculation in Part 13's benchmarking) actually needs — +// morgan's text format isn't reliably parseable for that. +module.exports = function requestLogger(req, res, next) { + const startedAt = process.hrtime.bigint(); + + res.on('finish', () => { + const durationMs = Number(process.hrtime.bigint() - startedAt) / 1e6; + const entry = { + requestId: req.id, + method: req.method, + path: req.originalUrl.split('?')[0], + statusCode: res.statusCode, + durationMs: Math.round(durationMs * 100) / 100, + userId: req.user?.id, + role: req.user?.role, + }; + if (res.statusCode >= 500) { + logger.error('request', entry); + } else if (res.statusCode >= 400) { + logger.warn('request', entry); + } else { + logger.info('request', entry); + } + }); + + next(); +}; diff --git a/node-api/src/middlewares/scopeInstitution.js b/node-api/src/middlewares/scopeInstitution.js index 9888e50..c2b285d 100644 --- a/node-api/src/middlewares/scopeInstitution.js +++ b/node-api/src/middlewares/scopeInstitution.js @@ -1,10 +1,22 @@ // Multi-tenant guardrail: everyone except super_admin is confined to their own institution. -// Forces list queries to filter by the caller's institution and overwrites any -// client-supplied institution_id on create/update — a client can never claim a +// Overwrites any client-supplied institution_id on create/update — a client can never claim a // different tenant's institution_id, even if it sends one in the request body. +// +// Does NOT (and, as of Express 5, cannot) inject institution scoping into list queries via +// req.query — Express 5 made req.query a read-only getter re-derived from the URL on each +// access; assigning to it (`req.query.institution_id = ...`) is silently a no-op; a prior +// version of this file did exactly that; it worked under Express 4 and produced a real, +// silent cross-tenant data leak the moment this app moved to Express 5, caught by an +// integration test (department listing) rather than by anything in Express itself. +// +// Every list endpoint must instead scope itself explicitly from `actor.institutionId` +// (the authenticated req.user, always available downstream regardless of body/query +// parsing) — see student.service.js#list / user.service.js#list / department.service.js#list +// for the established pattern. This middleware still exists for the create/update +// body-forcing behavior below, which — unlike req.query — is unaffected by Express 5 +// (req.body is a plain object populated once by the body-parser, not a live getter). module.exports = function scopeInstitution(req, res, next) { if (req.user.role !== 'super_admin') { - req.query.institution_id = req.user.institutionId; if (req.body && typeof req.body === 'object' && 'institution_id' in req.body) { req.body.institution_id = req.user.institutionId; } diff --git a/node-api/src/middlewares/upload.js b/node-api/src/middlewares/upload.js index 5f4734d..99e7466 100644 --- a/node-api/src/middlewares/upload.js +++ b/node-api/src/middlewares/upload.js @@ -2,24 +2,145 @@ const fs = require('fs'); const path = require('path'); const crypto = require('crypto'); const multer = require('multer'); +const ApiError = require('../utils/ApiError'); +const asyncHandler = require('../utils/asyncHandler'); // Mirrors python-service's uploads/profile layout so both the on-disk path and // the public /uploads/profile/ URL shape stay familiar across the migration. const uploadDir = path.join(process.cwd(), 'uploads', 'profile'); fs.mkdirSync(uploadDir, { recursive: true }); -const storage = multer.diskStorage({ - destination: (req, file, cb) => cb(null, uploadDir), - filename: (req, file, cb) => { - const ext = path.extname(file.originalname); - cb(null, `${crypto.randomUUID()}${ext}`); +// Every upload field in the app (onboardingSchemas.js's `profilePhoto` and +// `signature`) is an image — this endpoint has never needed anything else, +// so the allow-list is intentionally image-only rather than a general +// "safe file types" list. Two layers, both required: +// 1. extension + declared Content-Type, checked here (multer fileFilter) — +// cheap, rejects the obvious case before any bytes are read. +// 2. magic-byte signature, checked in verifyAndPersist below, against the +// actual uploaded bytes — the declared MIME type/extension are just +// what the client claims and are trivial to lie about (rename +// shell.php.jpg, or set Content-Type: image/png on an .exe); only the +// file's real header proves what it is. +const ALLOWED_TYPES = { + 'image/jpeg': { extensions: ['.jpg', '.jpeg'], magic: (buf) => buf[0] === 0xff && buf[1] === 0xd8 && buf[2] === 0xff }, + 'image/png': { + extensions: ['.png'], + magic: (buf) => buf.slice(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])), }, + 'image/gif': { + extensions: ['.gif'], + magic: (buf) => buf.slice(0, 6).equals(Buffer.from('GIF87a', 'ascii')) || buf.slice(0, 6).equals(Buffer.from('GIF89a', 'ascii')), + }, + 'image/webp': { + extensions: ['.webp'], + magic: (buf) => buf.slice(0, 4).toString('ascii') === 'RIFF' && buf.slice(8, 12).toString('ascii') === 'WEBP', + }, + // SVG is deliberately excluded even though it's "an image format" — it can + // embed