From d92bc4901aac3ba3c85530d3d941514d3356f31c Mon Sep 17 00:00:00 2001 From: Adam Xu Date: Tue, 7 Jul 2026 13:43:39 -0700 Subject: [PATCH 1/6] ci: add GitHub Actions, CodeQL, Dependabot, and repo governance Adds the repository automation this public repo was missing entirely: - CI: ci-frontend (typecheck hard gate; lint/build non-blocking ratchets) and backend (ruff check + byte-compile) on PRs to main - Security scanning: CodeQL (JS/TS + Python, security-extended) and bandit SAST (SARIF -> Security tab) - Dependabot: weekly pnpm (/frontend), pip (/backend), and github-actions updates, grouped minor+patch - Governance: LICENSE (MIT), SECURITY.md with private-advisory reporting, PR template with an IDOR/SSRF/authz security checklist, CODEOWNERS, issue templates, CONTRIBUTING.md - Tooling config: backend/ruff.toml (F,I select), requirements-dev.txt, root .pre-commit-config.yaml (ruff + detect-private-key + large-file guard) Co-Authored-By: Claude Opus 4.8 (1M context) --- .github/CODEOWNERS | 19 ++++++ .github/ISSUE_TEMPLATE/bug_report.yml | 50 +++++++++++++++ .github/ISSUE_TEMPLATE/config.yml | 8 +++ .github/ISSUE_TEMPLATE/feature_request.yml | 35 ++++++++++ .github/SECURITY.md | 65 +++++++++++++++++++ .github/dependabot.yml | 40 ++++++++++++ .github/pull_request_template.md | 38 +++++++++++ .github/workflows/backend.yml | 39 +++++++++++ .github/workflows/bandit.yml | 37 +++++++++++ .github/workflows/ci-frontend.yml | 47 ++++++++++++++ .github/workflows/codeql.yml | 41 ++++++++++++ .pre-commit-config.yaml | 19 ++++++ CONTRIBUTING.md | 75 ++++++++++++++++++++++ LICENSE | 21 ++++++ backend/requirements-dev.txt | 4 ++ backend/ruff.toml | 14 ++++ 16 files changed, 552 insertions(+) create mode 100644 .github/CODEOWNERS create mode 100644 .github/ISSUE_TEMPLATE/bug_report.yml create mode 100644 .github/ISSUE_TEMPLATE/config.yml create mode 100644 .github/ISSUE_TEMPLATE/feature_request.yml create mode 100644 .github/SECURITY.md create mode 100644 .github/dependabot.yml create mode 100644 .github/pull_request_template.md create mode 100644 .github/workflows/backend.yml create mode 100644 .github/workflows/bandit.yml create mode 100644 .github/workflows/ci-frontend.yml create mode 100644 .github/workflows/codeql.yml create mode 100644 .pre-commit-config.yaml create mode 100644 CONTRIBUTING.md create mode 100644 LICENSE create mode 100644 backend/requirements-dev.txt create mode 100644 backend/ruff.toml diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000..6395e30 --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,19 @@ +# CODEOWNERS for Pinewood One +# These owners are requested for review when someone opens a PR touching +# matching paths. See: +# https://docs.github.com/articles/about-code-owners + +# Default owner for everything in the repo. +* @AdamEXu + +# Top-level applications. +/frontend/ @AdamEXu +/backend/ @AdamEXu + +# Security-sensitive backend areas (extra scrutiny on review). +/backend/auth/ @AdamEXu +/backend/db/ @AdamEXu +/backend/services/scraper/ @AdamEXu + +# Repository automation and policy. +/.github/ @AdamEXu diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml new file mode 100644 index 0000000..4300f21 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -0,0 +1,50 @@ +name: Bug report +description: Report a problem with Pinewood One +labels: + - bug +body: + - type: markdown + attributes: + value: >- + Thanks for taking the time to file a bug! For **security + vulnerabilities**, do not use this form — report privately via a GitHub + security advisory (see .github/SECURITY.md). + - type: dropdown + id: area + attributes: + label: Area + description: Which part of the app is affected? + options: + - frontend + - backend + - scraper + - convex + - other + validations: + required: true + - type: textarea + id: what-happened + attributes: + label: What happened? + description: A clear description of the bug, including what you expected to happen. + validations: + required: true + - type: textarea + id: steps + attributes: + label: Steps to reproduce + description: How can we reproduce the problem? + placeholder: | + 1. Go to '...' + 2. Click on '...' + 3. See error + validations: + required: true + - type: textarea + id: environment + attributes: + label: Environment + description: Browser/OS, and whether this is local dev or a deployment. Do NOT paste secrets, tokens, or real student data. + placeholder: e.g. Chrome 130 on macOS 14, local dev + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000..b4bd258 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: Report a security vulnerability + url: https://github.com/Pinewood-Tech-Club/one/security/advisories/new + about: >- + Please do NOT file public issues for security problems. Report + vulnerabilities privately via a GitHub security advisory. See + .github/SECURITY.md for details. diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml new file mode 100644 index 0000000..8d2106f --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -0,0 +1,35 @@ +name: Feature request +description: Suggest an idea or improvement for Pinewood One +labels: + - enhancement +body: + - type: markdown + attributes: + value: Thanks for suggesting an improvement! Please describe the problem before the solution. + - type: textarea + id: problem + attributes: + label: Problem / motivation + description: What problem are you trying to solve? Why does it matter? + validations: + required: true + - type: textarea + id: solution + attributes: + label: Proposed solution + description: Describe what you'd like to happen. Include alternatives you've considered. + validations: + required: true + - type: dropdown + id: area + attributes: + label: Area + description: Which part of the app does this affect? + options: + - frontend + - backend + - scraper + - convex + - other + validations: + required: true diff --git a/.github/SECURITY.md b/.github/SECURITY.md new file mode 100644 index 0000000..9e5961d --- /dev/null +++ b/.github/SECURITY.md @@ -0,0 +1,65 @@ +# Security Policy + +Pinewood One handles **sensitive student data** — Schoology grades, schedules, +and assignments — as well as **OAuth tokens** and session credentials. We take +security reports seriously and appreciate responsible disclosure. + +## Reporting a vulnerability + +**Please do not open a public GitHub issue for security problems.** Public +issues disclose the vulnerability before it can be fixed. + +**Primary channel — GitHub private security advisory:** + +> https://github.com/Pinewood-Tech-Club/one/security/advisories/new + +Use the "Report a vulnerability" button on that page to open a private advisory. +This keeps the report confidential and lets us collaborate on a fix. + +If you cannot use the advisory workflow, contact a maintainer +(**@AdamEXu**) privately through GitHub and ask for a secure channel. Please do +not post vulnerability details in public comments, discussions, or issues. + +### What to include + +- A description of the vulnerability and its impact. +- Steps to reproduce (proof of concept if possible). +- Affected component(s) and any relevant version/commit. +- Any suggested remediation, if you have one. + +## Scope + +We are especially interested in reports affecting: + +- **Authentication & sessions** — Google OAuth login, session issuance, + expiration, and cookie handling. +- **OAuth token storage** — encryption at rest (Fernet), token handling, and + any path where tokens could leak (logs, error responses). +- **Scraper** (`backend/services/scraper/`) — server-side request forgery + (SSRF) via scraped URLs, and job-lease integrity / race conditions. +- **Chat SSE** — the server-sent-events chat stream (authorization, data + leakage across users). +- **IDOR / broken access control** — any endpoint returning user data without + verifying ownership. + +## Please avoid + +To protect real users while testing, please **do not**: + +- Scan, probe, or attack the **production Cloudflare tunnel** or any live + deployment. +- Access, exfiltrate, or modify **real student data** or another person's + account. +- Run automated scanners against production infrastructure. + +Test against a local development instance whenever possible. If you believe a +finding can only be demonstrated against production, describe it in the private +advisory and we will help reproduce it safely. + +## Our commitment + +- We will acknowledge your report as soon as we can. +- We will keep you informed as we investigate and remediate. +- We will credit reporters who wish to be acknowledged, once a fix is released. + +Thank you for helping keep Pinewood One and its users safe. diff --git a/.github/dependabot.yml b/.github/dependabot.yml new file mode 100644 index 0000000..1b921fc --- /dev/null +++ b/.github/dependabot.yml @@ -0,0 +1,40 @@ +version: 2 +updates: + # Frontend JavaScript/TypeScript dependencies (Next.js app) + - package-ecosystem: "npm" + directory: "/frontend" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "frontend" + groups: + minor-and-patch: + update-types: + - "minor" + - "patch" + + # Backend Python dependencies (Flask app) + - package-ecosystem: "pip" + directory: "/backend" + schedule: + interval: "weekly" + open-pull-requests-limit: 5 + labels: + - "dependencies" + - "backend" + groups: + minor-and-patch: + update-types: + - "minor" + - "patch" + + # GitHub Actions workflow dependencies + - package-ecosystem: "github-actions" + directory: "/" + schedule: + interval: "weekly" + labels: + - "dependencies" + - "ci" diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..c4fee01 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,38 @@ + + +## What & why + + + +## Area + + + +- [ ] Frontend +- [ ] Backend +- [ ] Scraper +- [ ] Convex +- [ ] Infra / tooling + +## How tested + + + +## Security checklist + + + +- [ ] Endpoints returning user data enforce **ownership / authorization** (no IDOR — a user cannot read another user's data). +- [ ] `@auth_required` is applied to routes that need authentication. +- [ ] No **SSRF**: any outbound/scraped URL is validated (scheme + host) before it is fetched. +- [ ] No secrets committed — no `.env`, `*.db`, keys, or credentials in the diff. +- [ ] Flask **debug mode is not forced on** in a production code path. +- [ ] OAuth **tokens stay encrypted** at rest and are **never logged** or returned in responses. + +## Checks + +- [ ] `cd frontend && pnpm lint` passes. +- [ ] Backend still starts and affected endpoints work (`make dev-backend`). diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml new file mode 100644 index 0000000..a68525f --- /dev/null +++ b/.github/workflows/backend.yml @@ -0,0 +1,39 @@ +name: backend + +on: + pull_request: + paths: + - "backend/**" + - ".github/workflows/backend.yml" + push: + branches: + - main + paths: + - "backend/**" + +jobs: + backend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: backend + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install ruff + run: pip install ruff==0.9.6 + + - name: Ruff lint + run: ruff check . + + # NOTE: `ruff format --check .` is intentionally omitted for now. + # A first-time format pass would reformat ~46 files, which does not + # belong in a security-focused PR. Enable it as a follow-up once a + # dedicated formatting commit lands. + + - name: Byte-compile + run: python -m compileall -q -x "env/" . diff --git a/.github/workflows/bandit.yml b/.github/workflows/bandit.yml new file mode 100644 index 0000000..f1e2a62 --- /dev/null +++ b/.github/workflows/bandit.yml @@ -0,0 +1,37 @@ +name: Bandit + +on: + push: + branches: + - main + pull_request: + branches: + - main + +permissions: + security-events: write + contents: read + +jobs: + bandit: + name: Bandit Python SAST + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install Bandit + run: pip install 'bandit[sarif]' + + - name: Run Bandit + run: bandit -r backend -x backend/env,backend/__pycache__ -f sarif -o bandit.sarif || true + + - name: Upload SARIF results + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: bandit.sarif diff --git a/.github/workflows/ci-frontend.yml b/.github/workflows/ci-frontend.yml new file mode 100644 index 0000000..2fd0fc4 --- /dev/null +++ b/.github/workflows/ci-frontend.yml @@ -0,0 +1,47 @@ +name: CI Frontend + +on: + pull_request: + branches: [main] + paths: + - 'frontend/**' + - '.github/workflows/ci-frontend.yml' + +jobs: + frontend: + runs-on: ubuntu-latest + defaults: + run: + working-directory: frontend + steps: + - uses: actions/checkout@v4 + + - uses: pnpm/action-setup@v4 + with: + version: 10 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + cache-dependency-path: frontend/pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Hard gate: the type checker is clean today and must stay clean. + - name: Typecheck + run: pnpm typecheck + + # Non-blocking ratchet: ~71 pre-existing eslint violations. Runs for + # visibility; flip to a hard gate once the backlog is burned down. + - name: Lint + run: pnpm lint + continue-on-error: true + + # Non-blocking for now: `next build` needs NEXT_PUBLIC_* env configured + # as repo secrets/vars to pass reliably. Make it a hard gate once those + # are wired up in repo settings. + - name: Build + run: pnpm build + continue-on-error: true diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml new file mode 100644 index 0000000..6a651a1 --- /dev/null +++ b/.github/workflows/codeql.yml @@ -0,0 +1,41 @@ +name: CodeQL + +on: + push: + branches: + - main + pull_request: + branches: + - main + schedule: + - cron: "27 4 * * 1" + +permissions: + security-events: write + actions: read + contents: read + +jobs: + analyze: + name: Analyze (${{ matrix.language }}) + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + language: + - javascript-typescript + - python + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Initialize CodeQL + uses: github/codeql-action/init@v3 + with: + languages: ${{ matrix.language }} + queries: security-extended + + - name: Perform CodeQL Analysis + uses: github/codeql-action/analyze@v3 + with: + category: "/language:${{ matrix.language }}" diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..1f635b4 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,19 @@ +repos: + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-merge-conflict + - id: check-added-large-files + args: ["--maxkb=1024"] + - id: detect-private-key + + - repo: https://github.com/astral-sh/ruff-pre-commit + rev: v0.9.6 + hooks: + - id: ruff + args: ["--fix"] + files: ^backend/ + - id: ruff-format + files: ^backend/ diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..c5a0dde --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,75 @@ +# Contributing to Pinewood One + +Thanks for your interest in contributing! Pinewood One is a full-stack app that +integrates Schoology data (grades, schedules, assignments) with a Next.js +frontend and a Flask backend. Because it handles sensitive student data and +OAuth tokens, we hold contributions to a high security bar. Please read this +guide before opening a pull request. + +## Getting set up + +The repository ships a `Makefile` that wraps the dev tooling. From the repo root: + +```bash +make setup # one-time: install deps and prepare the dev environment +make init # initialize local config / databases +make doctor # verify your environment is healthy +make secret-export # export the local secrets needed for development +``` + +To run the app during development: + +```bash +make dev # runs doctor, then starts the full stack +make dev-frontend # frontend only (Next.js) +make dev-backend # backend only (Flask) +make dev-convex # Convex dev deployment +``` + +Ports: + +- Frontend: **3112** +- Backend: **3111** + +If something looks off, run `make doctor` first — it checks each component +(`--component frontend`, `--component backend`, `--component convex`). + +## Workflow + +1. **Branch off `main`.** Create a topic branch for your change; do not commit + directly to `main`. +2. Make your change, keeping it focused and reasonably small. +3. **Lint the frontend** before pushing: + ```bash + cd frontend && pnpm lint + ``` +4. If you touch the backend, make sure it still starts (`make dev-backend`) and + that any affected endpoints behave as expected. +5. Open a pull request against `main` and **fill out the PR template**, + including the security checklist. PRs with an incomplete security checklist + will not be merged. + +## Security expectations + +Every contributor is responsible for not regressing security. When you open a +PR, confirm each item in the security checklist, in particular: + +- Endpoints that return user data enforce ownership / authorization (no IDOR) + and apply the `@auth_required` decorator where authentication is needed. +- Any outbound URL (especially in the scraper) is validated — no SSRF. +- OAuth tokens stay encrypted at rest and are never logged. +- Flask debug mode is never forced on in a production code path. + +**Never commit secrets.** Do not commit `.env` files, `*.db` databases, private +keys, or any credential. These paths are already covered by `.gitignore` — keep +them out of your diffs. + +## Reporting a vulnerability + +Do **not** open a public issue for security problems. Please follow the private +process described in [`.github/SECURITY.md`](.github/SECURITY.md). + +## Questions + +Open a regular issue using one of the issue templates (bug report or feature +request) for non-security topics. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..51c8349 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Pinewood Tech Club + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..b9ccd49 --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,4 @@ +ruff==0.9.6 +pytest==8.3.4 +pytest-cov==6.0.0 +# mypy==1.15.0 diff --git a/backend/ruff.toml b/backend/ruff.toml new file mode 100644 index 0000000..6044b98 --- /dev/null +++ b/backend/ruff.toml @@ -0,0 +1,14 @@ +target-version = "py312" +line-length = 100 +extend-exclude = ["env", "__pycache__"] + +# Start with the highest-signal, lowest-churn rule sets (pyflakes + import +# sorting). UP (pyupgrade) and B (flake8-bugbear) are valuable ratchet +# follow-ups but currently surface high-churn changes; enable them once the +# initial cleanup lands. +[lint] +select = ["F", "I"] +ignore = ["E501"] + +[format] +quote-style = "double" From a5a0217c854c76cdf1decd3d55d16c124bc21dbe Mon Sep 17 00:00:00 2001 From: Adam Xu Date: Tue, 7 Jul 2026 13:43:56 -0700 Subject: [PATCH 2/6] fix(frontend): migrate eslint to flat config; add typecheck script MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `pnpm lint` was hard-crashing (TypeError: Converting circular structure to JSON) because eslint.config.mjs pulled next/core-web-vitals via the legacy FlatCompat shim, which chokes on eslint-plugin-react-hooks@7's circular plugin object. Switch to eslint-config-next's native flat exports so eslint runs again. Also add a `typecheck` script (tsc --noEmit) — the CI hard gate — and pin packageManager to pnpm@10.30.3 plus a .nvmrc (node 22). Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/.nvmrc | 1 + frontend/eslint.config.mjs | 15 ++++----------- frontend/package.json | 4 +++- 3 files changed, 8 insertions(+), 12 deletions(-) create mode 100644 frontend/.nvmrc diff --git a/frontend/.nvmrc b/frontend/.nvmrc new file mode 100644 index 0000000..2bd5a0a --- /dev/null +++ b/frontend/.nvmrc @@ -0,0 +1 @@ +22 diff --git a/frontend/eslint.config.mjs b/frontend/eslint.config.mjs index 719cea2..6d22b8e 100644 --- a/frontend/eslint.config.mjs +++ b/frontend/eslint.config.mjs @@ -1,16 +1,9 @@ -import { dirname } from "path"; -import { fileURLToPath } from "url"; -import { FlatCompat } from "@eslint/eslintrc"; - -const __filename = fileURLToPath(import.meta.url); -const __dirname = dirname(__filename); - -const compat = new FlatCompat({ - baseDirectory: __dirname, -}); +import nextCoreWebVitals from "eslint-config-next/core-web-vitals"; +import nextTypescript from "eslint-config-next/typescript"; const eslintConfig = [ - ...compat.extends("next/core-web-vitals", "next/typescript"), + ...nextCoreWebVitals, + ...nextTypescript, { ignores: [ "node_modules/**", diff --git a/frontend/package.json b/frontend/package.json index 5dc14a0..ff7b766 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -2,11 +2,13 @@ "name": "chat", "version": "0.1.0", "private": true, + "packageManager": "pnpm@10.30.3", "scripts": { "dev": "next dev --turbopack -p 3112", "build": "next build --turbopack", "start": "next start -p 3112", - "lint": "eslint" + "lint": "eslint", + "typecheck": "tsc --noEmit" }, "dependencies": { "@types/mdx": "^2.0.13", From d57d9611e77c2d460cc626d03301b4ea7b32cced Mon Sep 17 00:00:00 2001 From: Adam Xu Date: Tue, 7 Jul 2026 13:43:56 -0700 Subject: [PATCH 3/6] chore(backend): ruff F/I baseline; fix undefined `Any` and unused assigns MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Applies ruff's safe autofixes (import sorting/pruning across ~30 files) so the new `ruff check` CI gate is green, and fixes the non-autofixable findings it surfaced: - services/chat/service.py: `list[dict[str, Any]]` used with no import of `Any` (F821) — a latent runtime NameError. Add `from typing import Any`. - api/routes.py: drop two unused `result =` assignments (kept the calls). Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/api/routes.py | 10 ++++---- backend/app.py | 13 ++++++---- backend/auth/google.py | 3 ++- backend/auth/jwt_utils.py | 2 +- backend/auth/middleware.py | 4 +++- backend/auth/mobile_middleware.py | 2 +- backend/auth/routes.py | 8 ++++--- backend/db/encryption.py | 1 + backend/db/sessions.py | 1 + backend/db/tokens.py | 2 +- backend/db/users.py | 1 + backend/extensions.py | 1 - backend/internal_chat/routes.py | 4 ++-- backend/mobile/routes.py | 4 ++-- backend/onboarding/__init__.py | 2 +- backend/schoology/routes.py | 24 +++++++++---------- backend/services/chat/schoology_tools.py | 6 ++--- backend/services/chat/service.py | 1 + backend/services/chat/worker.py | 11 ++++++--- backend/services/mobile/service.py | 6 +++-- backend/services/schoology/__init__.py | 2 +- backend/services/schoology/client.py | 13 +++++----- backend/services/schoology/refresh.py | 3 ++- backend/services/schoology/worker.py | 1 + .../services/scraper/attachment_download.py | 4 ++-- backend/services/scraper/extraction.py | 2 +- backend/services/scraper/scheduler.py | 2 +- backend/services/scraper/section_sync.py | 2 +- backend/services/scraper/store.py | 2 +- 29 files changed, 80 insertions(+), 57 deletions(-) diff --git a/backend/api/routes.py b/backend/api/routes.py index cbbd73a..812cfcb 100644 --- a/backend/api/routes.py +++ b/backend/api/routes.py @@ -6,9 +6,11 @@ from hashlib import sha256 from flask import Blueprint, Response, jsonify, request, stream_with_context -from config import Config + from auth.middleware import auth_required -from onboarding import get_user as convex_get_user, update_onboarding_step, save_consent +from config import Config +from onboarding import get_user as convex_get_user +from onboarding import save_consent, update_onboarding_step from services.chat import live_stream TERMINAL_CHAT_STATUSES = {"completed", "failed", "cancelled"} @@ -88,7 +90,7 @@ def start_onboarding(user): Updates onboarding_step from "welcome" to "connect_lms". """ try: - result = update_onboarding_step( + update_onboarding_step( Config.CONVEX_URL, str(user["id"]), "connect_lms" @@ -128,7 +130,7 @@ def save_user_consent(user): "version": str(version) } - result = save_consent( + save_consent( Config.CONVEX_URL, str(user["id"]), consent diff --git a/backend/app.py b/backend/app.py index 02cbb6a..2e21d9f 100644 --- a/backend/app.py +++ b/backend/app.py @@ -2,18 +2,21 @@ Pinewood One Backend - Main Application Entry Point """ import os + from flask import Flask, jsonify from flask_cors import CORS -from config import Config -from db.init import init_db -from extensions import limiter + +from api.routes import api_bp # Import blueprints from auth.routes import auth_bp -from api.routes import api_bp +from config import Config +from db.init import init_db +from extensions import limiter from internal_chat.routes import internal_chat_bp -from schoology.routes import oauth_bp as schoology_oauth_bp, schoology_api_bp from mobile.routes import mobile_bp +from schoology.routes import oauth_bp as schoology_oauth_bp +from schoology.routes import schoology_api_bp def create_app(): diff --git a/backend/auth/google.py b/backend/auth/google.py index ae2ed8f..b4fe434 100644 --- a/backend/auth/google.py +++ b/backend/auth/google.py @@ -5,9 +5,10 @@ import os import queue import threading +from urllib.parse import urlencode import requests -from urllib.parse import urlencode + from config import Config logger = logging.getLogger(__name__) diff --git a/backend/auth/jwt_utils.py b/backend/auth/jwt_utils.py index d4911ed..d0f9a70 100644 --- a/backend/auth/jwt_utils.py +++ b/backend/auth/jwt_utils.py @@ -1,8 +1,8 @@ """ JWT utilities for backend-issued RS256 tokens. """ -import os import base64 +import os import secrets from datetime import datetime, timedelta, timezone from pathlib import Path diff --git a/backend/auth/middleware.py b/backend/auth/middleware.py index 754cc96..e6acfda 100644 --- a/backend/auth/middleware.py +++ b/backend/auth/middleware.py @@ -2,7 +2,9 @@ Authentication middleware """ from functools import wraps -from flask import session, jsonify + +from flask import jsonify, session + from db.sessions import get_session diff --git a/backend/auth/mobile_middleware.py b/backend/auth/mobile_middleware.py index 17d17ef..2e0e3fa 100644 --- a/backend/auth/mobile_middleware.py +++ b/backend/auth/mobile_middleware.py @@ -3,7 +3,7 @@ """ from functools import wraps -from flask import jsonify, g, request +from flask import g, jsonify, request from auth.jwt_utils import verify_mobile_access_token from db.users import get_user_by_id diff --git a/backend/auth/routes.py b/backend/auth/routes.py index bfcce77..43fe70c 100644 --- a/backend/auth/routes.py +++ b/backend/auth/routes.py @@ -5,11 +5,13 @@ import logging import secrets import threading -from flask import Blueprint, redirect, request, session, jsonify + +from flask import Blueprint, jsonify, redirect, request, session + +from auth.google import exchange_code_for_token, get_google_auth_url, get_user_info from config import Config -from auth.google import get_google_auth_url, exchange_code_for_token, get_user_info -from db.users import get_or_create_user from db.sessions import create_session, delete_session +from db.users import get_or_create_user from onboarding import get_or_create_user as convex_get_or_create_user logger = logging.getLogger(__name__) diff --git a/backend/db/encryption.py b/backend/db/encryption.py index 0e96aba..c294612 100644 --- a/backend/db/encryption.py +++ b/backend/db/encryption.py @@ -2,6 +2,7 @@ Encryption utilities for secure token storage """ from cryptography.fernet import Fernet + from config import Config ENCRYPTION_KEY = Config.ENCRYPTION_KEY diff --git a/backend/db/sessions.py b/backend/db/sessions.py index b38f5d5..7e8b8f4 100644 --- a/backend/db/sessions.py +++ b/backend/db/sessions.py @@ -3,6 +3,7 @@ """ import secrets from datetime import datetime, timedelta + from config import Config from db.pool import get_conn from db.users import get_user_by_id diff --git a/backend/db/tokens.py b/backend/db/tokens.py index 46a7b72..3297311 100644 --- a/backend/db/tokens.py +++ b/backend/db/tokens.py @@ -5,8 +5,8 @@ from datetime import datetime from config import Config -from db.pool import get_conn from db.encryption import encrypt_token +from db.pool import get_conn def hash_schoology_request_token(request_token: str) -> str: diff --git a/backend/db/users.py b/backend/db/users.py index f8a9935..c28084d 100644 --- a/backend/db/users.py +++ b/backend/db/users.py @@ -2,6 +2,7 @@ User database operations """ from datetime import datetime + from config import Config from db.pool import get_conn diff --git a/backend/extensions.py b/backend/extensions.py index 2a417bc..ba957d2 100644 --- a/backend/extensions.py +++ b/backend/extensions.py @@ -4,7 +4,6 @@ from flask_limiter import Limiter from flask_limiter.util import get_remote_address - limiter = Limiter( key_func=get_remote_address, default_limits=[], diff --git a/backend/internal_chat/routes.py b/backend/internal_chat/routes.py index 4260adc..55550bb 100644 --- a/backend/internal_chat/routes.py +++ b/backend/internal_chat/routes.py @@ -2,11 +2,11 @@ Internal chat execution routes. """ import logging -from pathlib import Path import subprocess -import threading import sys +import threading import time +from pathlib import Path from flask import Blueprint, jsonify, request diff --git a/backend/mobile/routes.py b/backend/mobile/routes.py index e43c491..18c94f4 100644 --- a/backend/mobile/routes.py +++ b/backend/mobile/routes.py @@ -1,15 +1,15 @@ """ Mobile API routes (/api/mobile/v1/*). """ -from flask import Blueprint, jsonify, redirect, request, session, g +from flask import Blueprint, g, jsonify, redirect, request, session from flask_limiter.util import get_remote_address from auth.mobile_middleware import mobile_auth_required from config import Config from db.sessions import create_session from extensions import limiter -from services.mobile import service from onboarding import get_user as convex_get_user +from services.mobile import service from services.schoology.refresh import start_schoology_refresh_for_user mobile_bp = Blueprint("mobile_api", __name__, url_prefix="/api/mobile/v1") diff --git a/backend/onboarding/__init__.py b/backend/onboarding/__init__.py index 79a8dac..90f3921 100644 --- a/backend/onboarding/__init__.py +++ b/backend/onboarding/__init__.py @@ -4,9 +4,9 @@ from .convex_sync import ( get_or_create_user, get_user, + save_consent, update_onboarding_step, update_schoology_connected, - save_consent, ) __all__ = [ diff --git a/backend/schoology/routes.py b/backend/schoology/routes.py index 840def0..8c7d799 100644 --- a/backend/schoology/routes.py +++ b/backend/schoology/routes.py @@ -2,22 +2,22 @@ Schoology API routes. """ from flask import Blueprint, jsonify, redirect, request -from config import Config + from auth.middleware import auth_required -from services.schoology import start_oauth, complete_oauth -from services.schoology.convex_sync import sync_profile_picture -from services.schoology.refresh import start_schoology_refresh_for_user -from services.schoology.runtime import create_schoology_service +from config import Config +from db.encryption import decrypt_token from db.tokens import ( + delete_schoology_tokens, + get_schoology_request_token_record, + save_schoology_access_tokens, save_schoology_credentials, save_schoology_request_tokens, - save_schoology_access_tokens, - get_schoology_request_token_record, - delete_schoology_tokens ) -from db.encryption import decrypt_token -from onboarding import update_schoology_connected, update_onboarding_step -from services.schoology import SchoologyService +from onboarding import update_onboarding_step, update_schoology_connected +from services.schoology import SchoologyService, complete_oauth, start_oauth +from services.schoology.convex_sync import sync_profile_picture +from services.schoology.refresh import start_schoology_refresh_for_user +from services.schoology.runtime import create_schoology_service # Blueprint for /oauth/schoology/* routes oauth_bp = Blueprint('schoology_oauth', __name__, url_prefix='/oauth/schoology') @@ -222,7 +222,7 @@ def schoology_courses(user): print(f"[DEBUG] Failed to create Schoology service for user_id: {user['id']}") return jsonify({"error": "Schoology account not connected"}), 400 - print(f"[DEBUG] Fetching courses from Schoology API...") + print("[DEBUG] Fetching courses from Schoology API...") courses = service.get_courses() # Automatically syncs to Convex print(f"[DEBUG] Retrieved {len(courses)} courses") diff --git a/backend/services/chat/schoology_tools.py b/backend/services/chat/schoology_tools.py index 730d7f1..1b59e62 100644 --- a/backend/services/chat/schoology_tools.py +++ b/backend/services/chat/schoology_tools.py @@ -3,18 +3,18 @@ """ from __future__ import annotations -from dataclasses import dataclass import json -from pathlib import Path import re +from dataclasses import dataclass +from pathlib import Path from typing import Any from itsdangerous import BadSignature, URLSafeSerializer from config import Config from services.schoology.runtime import create_schoology_service -from services.scraper.extraction import read_extracted_attachment_text from services.scraper import store as scraper_store +from services.scraper.extraction import read_extracted_attachment_text class SchoologyToolError(RuntimeError): diff --git a/backend/services/chat/service.py b/backend/services/chat/service.py index 6dd474c..129949e 100644 --- a/backend/services/chat/service.py +++ b/backend/services/chat/service.py @@ -7,6 +7,7 @@ import threading import time from dataclasses import dataclass +from typing import Any from config import Config diff --git a/backend/services/chat/worker.py b/backend/services/chat/worker.py index c64c80e..23e5e7b 100644 --- a/backend/services/chat/worker.py +++ b/backend/services/chat/worker.py @@ -5,9 +5,14 @@ import sys import time -from . import ChatConfigurationError, ChatContractError, ChatGenerationNotFoundError, run_generation -from . import convex_sync -from . import live_stream +from . import ( + ChatConfigurationError, + ChatContractError, + ChatGenerationNotFoundError, + convex_sync, + live_stream, + run_generation, +) logger = logging.getLogger(__name__) diff --git a/backend/services/mobile/service.py b/backend/services/mobile/service.py index 14d3058..a83e55d 100644 --- a/backend/services/mobile/service.py +++ b/backend/services/mobile/service.py @@ -8,7 +8,7 @@ import re import secrets from datetime import timedelta -from urllib.parse import urlencode, urlparse, urlunparse, parse_qsl +from urllib.parse import parse_qsl, urlencode, urlparse, urlunparse import requests from itsdangerous import BadSignature, SignatureExpired, URLSafeTimedSerializer @@ -16,12 +16,14 @@ from auth.google import get_user_info from auth.jwt_utils import create_convex_token, create_mobile_access_token from config import Config -from db.encryption import decrypt_token, encrypt_token from db import mobile as mobile_db +from db.encryption import decrypt_token, encrypt_token from db.tokens import save_schoology_access_tokens from db.users import get_or_create_user, get_user_by_id from onboarding import ( get_or_create_user as convex_get_or_create_user, +) +from onboarding import ( update_onboarding_step, update_schoology_connected, ) diff --git a/backend/services/schoology/__init__.py b/backend/services/schoology/__init__.py index 2dfad9f..a1264f3 100644 --- a/backend/services/schoology/__init__.py +++ b/backend/services/schoology/__init__.py @@ -6,7 +6,7 @@ """ from .client import SchoologyService -from .oauth import start_oauth, complete_oauth +from .oauth import complete_oauth, start_oauth __version__ = "0.1.0" __all__ = ["SchoologyService", "start_oauth", "complete_oauth"] diff --git a/backend/services/schoology/client.py b/backend/services/schoology/client.py index f316863..d3061b7 100644 --- a/backend/services/schoology/client.py +++ b/backend/services/schoology/client.py @@ -2,18 +2,19 @@ Schoology API client wrapper """ import json -from pathlib import Path +from datetime import datetime, timedelta, timezone +from urllib.parse import urlencode, urljoin, urlparse from xml.sax.saxutils import escape -import schoolopy + import requests import requests_oauthlib -from datetime import datetime, timedelta, timezone -from urllib.parse import urlencode, urljoin, urlparse +import schoolopy + from .convex_sync import ( - sync_courses, + clear_cache, sync_course_assignments, + sync_courses, sync_profile_picture, - clear_cache, ) diff --git a/backend/services/schoology/refresh.py b/backend/services/schoology/refresh.py index 2407f16..37d34f6 100644 --- a/backend/services/schoology/refresh.py +++ b/backend/services/schoology/refresh.py @@ -2,13 +2,14 @@ Schoology refresh orchestration. """ import logging -from pathlib import Path import secrets import subprocess import sys +from pathlib import Path from config import Config from db.job_leases import acquire_schoology_refresh_lease, utcnow + from .runtime import create_schoology_service logger = logging.getLogger(__name__) diff --git a/backend/services/schoology/worker.py b/backend/services/schoology/worker.py index f08fed3..4bcc095 100644 --- a/backend/services/schoology/worker.py +++ b/backend/services/schoology/worker.py @@ -5,6 +5,7 @@ import sys from db.job_leases import release_schoology_refresh_lease + from .refresh import run_schoology_refresh_for_user logger = logging.getLogger(__name__) diff --git a/backend/services/scraper/attachment_download.py b/backend/services/scraper/attachment_download.py index 1d2129e..3f08579 100644 --- a/backend/services/scraper/attachment_download.py +++ b/backend/services/scraper/attachment_download.py @@ -4,16 +4,16 @@ from __future__ import annotations import hashlib -from pathlib import Path import re +from pathlib import Path from typing import Any from urllib.parse import urlparse from config import Config from services.schoology.client import SchoologyService -from .extraction import extract_attachment_text_if_needed from . import store +from .extraction import extract_attachment_text_if_needed from .google_drive import download_google_drive_link_if_needed, google_drive_url_info diff --git a/backend/services/scraper/extraction.py b/backend/services/scraper/extraction.py index 3c5929e..9c97f58 100644 --- a/backend/services/scraper/extraction.py +++ b/backend/services/scraper/extraction.py @@ -3,9 +3,9 @@ """ from __future__ import annotations -from functools import lru_cache import json import logging +from functools import lru_cache from pathlib import Path from typing import Any diff --git a/backend/services/scraper/scheduler.py b/backend/services/scraper/scheduler.py index 736a0be..d8185d5 100644 --- a/backend/services/scraper/scheduler.py +++ b/backend/services/scraper/scheduler.py @@ -4,11 +4,11 @@ from __future__ import annotations import logging -from pathlib import Path import secrets import subprocess import sys import time +from pathlib import Path from typing import Any from config import Config diff --git a/backend/services/scraper/section_sync.py b/backend/services/scraper/section_sync.py index 8fd98a0..86df186 100644 --- a/backend/services/scraper/section_sync.py +++ b/backend/services/scraper/section_sync.py @@ -3,9 +3,9 @@ """ from __future__ import annotations -from datetime import datetime import hashlib import logging +from datetime import datetime from pathlib import Path from typing import Any from urllib.parse import urlparse diff --git a/backend/services/scraper/store.py b/backend/services/scraper/store.py index 57e272a..23bf18f 100644 --- a/backend/services/scraper/store.py +++ b/backend/services/scraper/store.py @@ -3,10 +3,10 @@ """ from __future__ import annotations -from datetime import datetime, timedelta, timezone import hashlib import json import re +from datetime import datetime, timedelta, timezone from typing import Any from config import Config From 05ad5fef8cdcd84d8a34b2b69c2807f18267a00e Mon Sep 17 00:00:00 2001 From: Adam Xu Date: Tue, 7 Jul 2026 13:43:56 -0700 Subject: [PATCH 4/6] test(backend): add pytest scaffolding and security-critical tests First tests in the repo, targeting the security-sensitive pure functions behind the recent fix/* branches: - test_encryption.py: token encrypt/decrypt round-trip; fails closed on tampered/garbage ciphertext - test_middleware.py: @auth_required returns 401 without a valid session, injects the user with one - test_jwt.py: audience/issuer/exp enforcement; rejects alg=none and tampered signatures conftest.py sets a generated Fernet key + required env before import so runs are hermetic. 17 tests, all passing. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/pytest.ini | 3 + backend/tests/conftest.py | 50 ++++++++++++++++ backend/tests/test_encryption.py | 56 ++++++++++++++++++ backend/tests/test_jwt.py | 99 ++++++++++++++++++++++++++++++++ backend/tests/test_middleware.py | 72 +++++++++++++++++++++++ 5 files changed, 280 insertions(+) create mode 100644 backend/pytest.ini create mode 100644 backend/tests/conftest.py create mode 100644 backend/tests/test_encryption.py create mode 100644 backend/tests/test_jwt.py create mode 100644 backend/tests/test_middleware.py diff --git a/backend/pytest.ini b/backend/pytest.ini new file mode 100644 index 0000000..eeb9d41 --- /dev/null +++ b/backend/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +addopts = -q diff --git a/backend/tests/conftest.py b/backend/tests/conftest.py new file mode 100644 index 0000000..8d71650 --- /dev/null +++ b/backend/tests/conftest.py @@ -0,0 +1,50 @@ +""" +Shared pytest fixtures and environment bootstrap. + +The backend is not an installed package and several modules read required +configuration at import time (e.g. db/encryption.py builds a Fernet instance +from Config.ENCRYPTION_KEY on import). So we must: + 1. Put the backend root on sys.path so `import config`, `import app`, etc. work. + 2. Populate the required env vars BEFORE any backend module is imported, + using a *valid* Fernet key, so imports are deterministic and never touch + the real keys/ files. +""" +import os +import sys +from pathlib import Path + +# --- 1. Make the backend root importable ----------------------------------- +BACKEND_ROOT = Path(__file__).resolve().parents[1] +if str(BACKEND_ROOT) not in sys.path: + sys.path.insert(0, str(BACKEND_ROOT)) + +# --- 2. Set required env vars before importing anything backend-related ---- +# Force development mode so Config never raises on missing production secrets. +os.environ.setdefault("FLASK_ENV", "development") + +# A valid Fernet key is required by db/encryption.py at import time. +from cryptography.fernet import Fernet # noqa: E402 + +os.environ.setdefault("ENCRYPTION_KEY", Fernet.generate_key().decode("utf-8")) +os.environ.setdefault("FLASK_SECRET_KEY", "test-secret-key-not-for-production") + +# Deterministic RS256 keypair location / issuer for JWT tests. +os.environ.setdefault("JWT_ISSUER", "http://localhost:3111") + +import pytest # noqa: E402 + + +@pytest.fixture(scope="session") +def app(): + """Full Flask app from the application factory.""" + from app import create_app + + application = create_app() + application.config.update(TESTING=True) + return application + + +@pytest.fixture() +def client(app): + """Flask test client.""" + return app.test_client() diff --git a/backend/tests/test_encryption.py b/backend/tests/test_encryption.py new file mode 100644 index 0000000..7151a5f --- /dev/null +++ b/backend/tests/test_encryption.py @@ -0,0 +1,56 @@ +""" +Tests for db/encryption.py — token encryption at rest (Fernet). + +Security properties under test: + - round-trip fidelity (encrypt then decrypt yields the original secret) + - ciphertext is not plaintext + - None / empty inputs are handled without raising + - tampered / malformed ciphertext FAILS CLOSED (returns None, never the plaintext) +""" +from db.encryption import decrypt_token, encrypt_token + + +def test_round_trip(): + token = "super-secret-oauth-access-token-12345" + encrypted = encrypt_token(token) + assert encrypted is not None + assert encrypted != token # must not store plaintext + assert decrypt_token(encrypted) == token + + +def test_round_trip_unicode(): + token = "tökén-ünïcodé-🔐" + assert decrypt_token(encrypt_token(token)) == token + + +def test_encrypt_is_nondeterministic(): + # Fernet includes a random IV, so two encryptions differ but both decrypt. + token = "same-input" + a = encrypt_token(token) + b = encrypt_token(token) + assert a != b + assert decrypt_token(a) == token + assert decrypt_token(b) == token + + +def test_encrypt_none_and_empty_return_none(): + assert encrypt_token(None) is None + assert encrypt_token("") is None + + +def test_decrypt_none_and_empty_return_none(): + assert decrypt_token(None) is None + assert decrypt_token("") is None + + +def test_tampered_ciphertext_fails_closed(): + token = "another-secret" + encrypted = encrypt_token(token) + # Flip the last character to corrupt the token / auth tag. + tampered = encrypted[:-1] + ("A" if encrypted[-1] != "A" else "B") + result = decrypt_token(tampered) + assert result is None # must NOT return the plaintext, must NOT raise + + +def test_garbage_ciphertext_fails_closed(): + assert decrypt_token("this-is-not-a-valid-fernet-token") is None diff --git a/backend/tests/test_jwt.py b/backend/tests/test_jwt.py new file mode 100644 index 0000000..3c0991c --- /dev/null +++ b/backend/tests/test_jwt.py @@ -0,0 +1,99 @@ +""" +Tests for auth/jwt_utils.py — backend-issued RS256 JWTs. + +Security properties under test: + - mint + verify round-trip for each audience + - audience is enforced (a convex token must NOT verify as a mobile token) + - issuer is enforced (wrong issuer -> None) + - expired tokens are rejected (fail closed -> None) + - tampered signature is rejected + - the "none" algorithm / unsigned tokens are rejected (alg confusion) +""" +import time + +import jwt as pyjwt +import pytest + +from auth import jwt_utils +from auth.jwt_utils import ( + JWT_CONVEX_AUDIENCE, + JWT_ISSUER, + JWT_MOBILE_AUDIENCE, + create_convex_token, + create_mobile_access_token, + verify_convex_token, + verify_mobile_access_token, + verify_token, +) + + +def test_convex_token_round_trip(): + token = create_convex_token(user_id=7, email="a@b.com", name="A B") + claims = verify_convex_token(token) + assert claims is not None + assert claims["sub"] == "7" + assert claims["aud"] == JWT_CONVEX_AUDIENCE + assert claims["iss"] == JWT_ISSUER + assert "exp" in claims and "iat" in claims + + +def test_mobile_token_round_trip_has_device_and_jti(): + token = create_mobile_access_token( + user_id=9, email="m@b.com", name="M B", device_id="device-xyz" + ) + claims = verify_mobile_access_token(token) + assert claims is not None + assert claims["aud"] == JWT_MOBILE_AUDIENCE + assert claims["device_id"] == "device-xyz" + assert "jti" in claims + + +def test_audience_is_enforced(): + convex = create_convex_token(user_id=1, email="a@b.com", name="A") + # A convex-audience token must not validate as a mobile token. + assert verify_mobile_access_token(convex) is None + + +def test_wrong_issuer_rejected(): + token = create_convex_token(user_id=1, email="a@b.com", name="A") + # Verifying against an audience is fine, but a mismatched issuer must fail. + public_key = jwt_utils._load_public_key() + with pytest.raises(pyjwt.InvalidIssuerError): + pyjwt.decode( + token, + public_key, + algorithms=[jwt_utils.JWT_ALGORITHM], + audience=JWT_CONVEX_AUDIENCE, + issuer="https://evil.example.com", + ) + + +def test_expired_token_rejected(): + token = create_convex_token( + user_id=1, email="a@b.com", name="A", expires_in_seconds=1 + ) + time.sleep(2) + assert verify_convex_token(token) is None + + +def test_tampered_token_rejected(): + token = create_convex_token(user_id=1, email="a@b.com", name="A") + # Corrupt the signature segment. + header, payload, sig = token.split(".") + tampered = ".".join([header, payload, sig[:-2] + "AA"]) + assert verify_token(tampered, audience=JWT_CONVEX_AUDIENCE) is None + + +def test_unsigned_none_alg_rejected(): + # Alg-confusion: an attacker-crafted unsigned token must never verify. + forged = pyjwt.encode( + { + "sub": "1", + "aud": JWT_CONVEX_AUDIENCE, + "iss": JWT_ISSUER, + "exp": int(time.time()) + 3600, + }, + key="", + algorithm="none", + ) + assert verify_convex_token(forged) is None diff --git a/backend/tests/test_middleware.py b/backend/tests/test_middleware.py new file mode 100644 index 0000000..135e577 --- /dev/null +++ b/backend/tests/test_middleware.py @@ -0,0 +1,72 @@ +""" +Tests for auth/middleware.py — the @auth_required decorator. + +Security properties under test: + - missing session cookie -> 401 + - invalid/expired session -> 401 (and the stale session_id is cleared) + - valid session -> handler runs, resolved user injected as kwargs["user"] + +We build a minimal Flask app and monkeypatch the session lookup that the +decorator imported into its own namespace (auth.middleware.get_session). +""" +import flask +import pytest + +from auth.middleware import auth_required + + +@pytest.fixture() +def app(): + application = flask.Flask(__name__) + application.secret_key = "test-secret" + + @application.route("/protected") + @auth_required + def protected(user): + return flask.jsonify({"user": user}) + + @application.route("/login-fake") + def login_fake(): + # Helper route to seat a session_id cookie for the "valid" path. + flask.session["session_id"] = "valid-session-id" + return "ok" + + return application + + +@pytest.fixture() +def client(app): + return app.test_client() + + +def test_no_session_returns_401(client): + resp = client.get("/protected") + assert resp.status_code == 401 + assert resp.get_json()["error"] == "Authentication required" + + +def test_invalid_session_returns_401(client, monkeypatch): + # Seat a session_id cookie... + client.get("/login-fake") + # ...but the lookup resolves to no user (expired/forged session). + monkeypatch.setattr("auth.middleware.get_session", lambda sid: None) + resp = client.get("/protected") + assert resp.status_code == 401 + assert resp.get_json()["error"] == "Invalid session" + + +def test_valid_session_injects_user(client, monkeypatch): + fake_user = {"id": 42, "email": "student@example.com", "name": "Test Student"} + called_with = {} + + def fake_get_session(sid): + called_with["sid"] = sid + return fake_user + + monkeypatch.setattr("auth.middleware.get_session", fake_get_session) + + client.get("/login-fake") + resp = client.get("/protected") + assert resp.status_code == 200 + assert resp.get_json()["user"] == fake_user + assert called_with["sid"] == "valid-session-id" From 7fdfcb9b26dccaff590b63831cd89046b7a7fca2 Mon Sep 17 00:00:00 2001 From: Adam Xu Date: Tue, 7 Jul 2026 13:51:30 -0700 Subject: [PATCH 5/6] fix(auth): stop logging raw request.args in OAuth callback CodeQL flagged a log-injection sink: request.args was logged verbatim at DEBUG, letting an attacker forge log lines via CR/LF and leaking the OAuth code/state into logs. Log only sanitized key names instead. Co-Authored-By: Claude Opus 4.8 (1M context) --- backend/auth/routes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/backend/auth/routes.py b/backend/auth/routes.py index 43fe70c..3ad7a61 100644 --- a/backend/auth/routes.py +++ b/backend/auth/routes.py @@ -48,7 +48,7 @@ def auth_google(): def auth_google_callback(): """Handle Google OAuth callback""" logger.debug("Google OAuth callback hit") - logger.debug(f"Request args: {request.args}") + logger.debug("Request args keys: %s", sorted(request.args.keys())) try: returned_state = request.args.get("state", "") expected_state = session.pop("google_oauth_state", None) From f1b3cbf11f1258e04eda446da36bdf1f06f564df Mon Sep 17 00:00:00 2001 From: Adam Xu Date: Tue, 7 Jul 2026 14:10:11 -0700 Subject: [PATCH 6/6] CI: actually run the pytest suite; ruff-fix imported baseline test files The backend workflow lint+compile gates never executed the tests this PR ships. Install requirements.txt + requirements-test.txt and run pytest as a hard gate, and also trigger on pushes to dev (the integration branch). Import-sort the baseline test files so the merged tree passes its own ruff gate. Co-Authored-By: Claude Fable 5 --- .github/workflows/backend.yml | 7 +++++++ backend/tests/test_encryption.py | 2 +- backend/tests/test_mobile_tokens.py | 2 +- backend/tests/test_scraper_section_leases.py | 2 +- 4 files changed, 10 insertions(+), 3 deletions(-) diff --git a/.github/workflows/backend.yml b/.github/workflows/backend.yml index a68525f..e41cb4d 100644 --- a/.github/workflows/backend.yml +++ b/.github/workflows/backend.yml @@ -8,6 +8,7 @@ on: push: branches: - main + - dev paths: - "backend/**" @@ -37,3 +38,9 @@ jobs: - name: Byte-compile run: python -m compileall -q -x "env/" . + + - name: Install test dependencies + run: pip install -r requirements.txt -r requirements-test.txt + + - name: Run tests + run: python -m pytest diff --git a/backend/tests/test_encryption.py b/backend/tests/test_encryption.py index 218e4c6..d779136 100644 --- a/backend/tests/test_encryption.py +++ b/backend/tests/test_encryption.py @@ -5,7 +5,7 @@ import pytest from cryptography.fernet import Fernet, InvalidToken -from db.encryption import encrypt_token, decrypt_token +from db.encryption import decrypt_token, encrypt_token from db.init import hash_schoology_request_token diff --git a/backend/tests/test_mobile_tokens.py b/backend/tests/test_mobile_tokens.py index bac3f0a..d2c4850 100644 --- a/backend/tests/test_mobile_tokens.py +++ b/backend/tests/test_mobile_tokens.py @@ -15,8 +15,8 @@ insert_mobile_auth_code, insert_mobile_refresh_token, insert_mobile_web_ticket, - rotate_mobile_refresh_token, revoke_mobile_refresh_token_for_user, + rotate_mobile_refresh_token, utcnow, ) from db.pool import get_conn diff --git a/backend/tests/test_scraper_section_leases.py b/backend/tests/test_scraper_section_leases.py index 2dd9879..7aa8079 100644 --- a/backend/tests/test_scraper_section_leases.py +++ b/backend/tests/test_scraper_section_leases.py @@ -5,8 +5,8 @@ """ from datetime import timedelta -from db.pool import get_conn from config import Config +from db.pool import get_conn from services.scraper.store import ( acquire_section_run, complete_section_run,