Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/actions-static-analysis.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: GitHub Actions Static Analysis

on:
push:
branches:
- "main"
paths:
- ".github/workflows/**"
pull_request:
paths:
- ".github/workflows/**"

permissions: {}

jobs:
zizmor:
name: Run zizmor
runs-on: ubuntu-latest
permissions:
contents: read
actions: read
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Run zizmor 🌈
uses: zizmorcore/zizmor-action@3dc1ecc9bcb9e94e9b2c709687979e1298497054 # v0.6.2
with:
inputs: ".github/workflows/"
min-severity: medium
min-confidence: medium
advanced-security: false
45 changes: 31 additions & 14 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
name: CI
on: [push]

permissions:
contents: read

jobs:
python-tests:
runs-on: ubuntu-24.04
Expand Down Expand Up @@ -28,7 +32,9 @@ jobs:
- 6379:6379

steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false

- name: update apt
run: sudo apt-get update -y
Expand All @@ -37,7 +43,7 @@ jobs:
run: cat Aptfile | sudo xargs apt-get install

- name: Install uv
uses: astral-sh/setup-uv@v7
uses: astral-sh/setup-uv@37802adc94f370d6bfd71619e3f0bf239e1f3b78 # v7.6.0
with:
enable-cache: true

Expand Down Expand Up @@ -84,8 +90,10 @@ jobs:
javascript-tests:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: "^24.0.0"
cache: yarn
Expand Down Expand Up @@ -117,10 +125,11 @@ jobs:
uses: SimenB/github-actions-cpu-cores@97ba232459a8e02ff6121db9362b09661c875ab8 # v2

- name: Tests
run: yarn test --max-workers ${{ steps.cpu-cores.outputs.count }}
run: yarn test --max-workers ${STEPS_CPU_CORES_OUTPUTS_COUNT}
env:
CODECOV: true
NODE_ENV: test
STEPS_CPU_CORES_OUTPUTS_COUNT: ${{ steps.cpu-cores.outputs.count }}

- name: Upload coverage to CodeCov
uses: codecov/codecov-action@671740ac38dd9b0130fbe1cec585b89eea48d3de # v5.5.2
Expand All @@ -132,7 +141,9 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false

- name: Build the Docker image
env:
Expand All @@ -147,9 +158,11 @@ jobs:
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false

- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: "^24.0.0"
cache: yarn
Expand All @@ -171,8 +184,10 @@ jobs:
GENERATOR_OUTPUT_DIR_VC: ./frontends/api/src/generated/v0
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: "^24.0.0"
cache: yarn
Expand All @@ -182,7 +197,7 @@ jobs:
run: yarn install --immutable

- name: Generate Fresh API Client
uses: openapi-generators/openapitools-generator-action@v1
uses: openapi-generators/openapitools-generator-action@515e8d70646f72de54d61c7f54b9447b7a1176f0 # v1
with:
generator: typescript-axios
openapi-file: $OPENAPI_SCHEMA
Expand Down Expand Up @@ -210,8 +225,10 @@ jobs:
GENERATOR_OUTPUT_DIR_VC: ./frontends/api/src/generated/v1
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
- uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: "^24.0.0"
cache: yarn
Expand All @@ -221,7 +238,7 @@ jobs:
run: yarn install --immutable

- name: Generate Fresh API Client
uses: openapi-generators/openapitools-generator-action@v1
uses: openapi-generators/openapitools-generator-action@515e8d70646f72de54d61c7f54b9447b7a1176f0 # v1
with:
generator: typescript-axios
openapi-file: $OPENAPI_SCHEMA
Expand Down
9 changes: 7 additions & 2 deletions .github/workflows/openapi-diff.yml
Original file line number Diff line number Diff line change
Expand Up @@ -3,17 +3,22 @@ on: [pull_request]
jobs:
openapi-diff:
runs-on: ubuntu-24.04
permissions:
contents: read
pull-requests: write
steps:
- name: Checkout HEAD
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ github.head_ref }}
path: head
persist-credentials: false
- name: Checkout BASE
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
ref: ${{ github.base_ref }}
path: base
persist-credentials: false
- name: Generate oasdiff summary
id: oasdif_changelog
run: |
Expand Down
9 changes: 7 additions & 2 deletions .github/workflows/publish-pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -8,14 +8,19 @@ on:
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:

permissions:
contents: read

jobs:
build:
runs-on: ubuntu-24.04
steps:
- name: Checkout
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6
uses: actions/checkout@8e8c483db84b4bee98b60c0593521ed34d9990e8 # v6.0.1
with:
persist-credentials: false

- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6
- uses: actions/setup-node@6044e13b5dc448c55e2357c09f80417699197238 # v6.2.0
with:
node-version: "^24.0.0"
cache: yarn
Expand Down
5 changes: 5 additions & 0 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,11 @@ repos:
hooks:
- id: shellcheck
args: ["--severity=warning"]
- repo: https://github.com/zizmorcore/zizmor-pre-commit
rev: v1.29.0
hooks:
- id: zizmor
args: [--no-progress, --min-severity=medium, --min-confidence=medium]
- repo: local
hooks:
- id: drf-serializer-orm-check
Expand Down
15 changes: 15 additions & 0 deletions RELEASE.rst
Original file line number Diff line number Diff line change
@@ -1,6 +1,21 @@
Release Notes
=============

Version 0.77.3
--------------

- fix broken images in subscription emails (#3737)
- Fix: render podcast show descriptions as HTML instead of raw markup (#3721)
- feat(content_feedback): allow anonymous submissions (#3738)
- Fix Canvas archive change detection: deterministic checksum, save after load (#3728)
- Skip unchanged edX course archives before downloading from S3 (#3722)
- remove GITHUB_ACCESS_TOKEN (#3730)
- Fix: HTML leaking into meta description tags (#3727)
- Make recreate index resilient to pod culling (#3716)
- Fix PostHog view-event ETL crash from duplicate view events (#3714)
- Use CMS Certificate Title for program LinkedIn "Add to Profile" (#3518)
- Harden GH Actions supply chain: add zizmor static analysis + 7-day dependency cool-down (#3712)

Version 0.77.2 (Released August 10, 2026)
--------------

Expand Down
12 changes: 8 additions & 4 deletions content_feedback/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

from drf_spectacular.utils import OpenApiResponse, extend_schema
from rest_framework.generics import CreateAPIView
from rest_framework.permissions import IsAuthenticated
from rest_framework.permissions import AllowAny

from content_feedback.models import ContentFeedback
from content_feedback.serializers import ContentFeedbackSerializer
Expand All @@ -22,10 +22,14 @@ class ContentFeedbackView(CreateAPIView):

queryset = ContentFeedback.objects.all()
serializer_class = ContentFeedbackSerializer
permission_classes = (IsAuthenticated,)
# AllowAny: courseware-only learners have no mit-learn/APISIX session, so
# requiring auth would 403 nearly all of them. Authenticated rows record the
# user; anonymous rows store null (mirrors learn-ai/AskTIM).
permission_classes = (AllowAny,)
throttle_classes = (RedisScopedRateThrottle,)
throttle_scope = "content_feedback"

def perform_create(self, serializer):
"""Attribute the feedback to the authenticated user (server-side)."""
serializer.save(user=self.request.user)
"""Attribute to the request user when authenticated, else store null."""
user = self.request.user if self.request.user.is_authenticated else None
serializer.save(user=user)
24 changes: 20 additions & 4 deletions content_feedback/views_test.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

import pytest
from django.urls import reverse
from rest_framework.test import APIClient

from content_feedback.factories import ContentFeedbackFactory
from content_feedback.models import ContentFeedback
Expand All @@ -26,10 +27,24 @@ def _payload(**overrides):
return payload


def test_submit_requires_authentication(client):
"""Anonymous users cannot submit feedback."""
def test_submit_allows_anonymous():
"""Anonymous users can submit without a CSRF token; the record has no user."""
# enforce_csrf_checks=True mirrors production SessionAuthentication: CSRF is
# only enforced for session-authenticated callers, so an anonymous POST with
# no token still succeeds.
client = APIClient(enforce_csrf_checks=True)
response = client.post(reverse("content_feedback:v0:content_feedback"), _payload())
assert response.status_code in (401, 403)
assert response.status_code == 201
assert ContentFeedback.objects.count() == 1
assert ContentFeedback.objects.get().user is None


def test_authenticated_without_csrf_token_rejected(user):
"""A session-authenticated POST without a CSRF token is rejected (403)."""
client = APIClient(enforce_csrf_checks=True)
client.force_login(user)
response = client.post(reverse("content_feedback:v0:content_feedback"), _payload())
assert response.status_code == 403
assert ContentFeedback.objects.count() == 0


Expand Down Expand Up @@ -119,7 +134,6 @@ def test_factory_builds_valid_record():
def test_submit_rate_limited(user_client, mocker):
"""Exceeding the per-user rate returns 429; a different user is unaffected."""
from django.core.cache.backends.locmem import LocMemCache
from rest_framework.test import APIClient

from main.factories import UserFactory
from main.throttles import RedisScopedRateThrottle
Expand All @@ -141,6 +155,8 @@ def test_submit_rate_limited(user_client, mocker):
# The limit is keyed per authenticated user: a different user still gets
# through even after the first user is throttled. (The user_client fixture
# shares one APIClient, so build a distinct client for the second user.)
# NB: anonymous requests instead key on client IP, which is spoofable via
# X-Forwarded-For -- tracked as a follow-up (mitodl/hq#12775).
other_client = APIClient()
other_client.force_login(UserFactory.create())
assert other_client.post(url, _payload()).status_code == 201
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,15 @@ const makePodcastEpisodes = (count: number): PodcastEpisodeResource[] =>
const setupApis = ({
episodesPage1,
episodesPage2,
podcastOverrides = {},
}: {
episodesPage1: LearningResource[]
episodesPage2?: LearningResource[]
podcastOverrides?: Partial<LearningResource>
}) => {
const podcast = factories.learningResources.resource({
resource_type: ResourceTypeEnum.Podcast,
...podcastOverrides,
})

// Episodes of this podcast reference it as their parent, as they would in
Expand Down Expand Up @@ -202,6 +205,43 @@ describe("PodcastDetailPage", () => {
await screen.findByText(episodes[0].title!)
})

test("renders a formatted show description", async () => {
const episodes = makePodcastEpisodes(1)
const { podcast } = setupApis({
episodesPage1: episodes,
podcastOverrides: {
description: "<p>Daryl Morey &amp; Jessica Gelman</p>",
},
})

renderWithProviders(<PodcastDetailPage podcastId={String(podcast.id)} />)

expect(
await screen.findByText("Daryl Morey & Jessica Gelman"),
).toBeInTheDocument()
})

test("opens external links in the show description in a new tab", async () => {
const episodes = makePodcastEpisodes(1)
const { podcast } = setupApis({
episodesPage1: episodes,
podcastOverrides: {
// rel="noopener noreferrer" mirrors real backend output: nh3 adds it
// to every <a> during ETL sanitization, regardless of destination.
description:
'Relevant Resources: <a href="https://ocw.mit.edu/" rel="noopener noreferrer">OCW</a> and <a href="/search" rel="noopener noreferrer">Search</a>.',
},
})

renderWithProviders(<PodcastDetailPage podcastId={String(podcast.id)} />)

const externalLink = await screen.findByRole("link", { name: "OCW" })
expect(externalLink).toHaveAttribute("target", "_blank")

const internalLink = screen.getByRole("link", { name: "Search" })
expect(internalLink).not.toHaveAttribute("target")
})

test("shows an error when the podcast fails to load", async () => {
const podcast = factories.learningResources.resource({
resource_type: ResourceTypeEnum.Podcast,
Expand Down
Loading
Loading