Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
14b882f
ci: generalize deploy-backend.yml to cover dev and prod
saqibmanan Sep 11, 2026
cd29cd1
Merge pull request #156 from CivicDataLab/ci/prod-deploy-pipeline-dev
saqibmanan Sep 11, 2026
3ecac4e
fix: recover 3 real hotfixes made directly on prod-cds, never committed
saqibmanan Sep 11, 2026
5b60872
docs: fix stale Keycloak domain and /auth prefix across SDK docs
saqibmanan Sep 11, 2026
b453612
Merge pull request #158 from CivicDataLab/fix/prod-hotfixes-from-host…
saqibmanan Sep 11, 2026
53d6031
Merge pull request #160 from CivicDataLab/fix/stale-keycloak-domain-d…
saqibmanan Sep 11, 2026
7c4542d
fix: register debug_toolbar in INSTALLED_APPS when DEBUG is on
saqibmanan Sep 11, 2026
bc6c85f
Merge pull request #162 from CivicDataLab/fix/debug-toolbar-installed…
saqibmanan Sep 11, 2026
ce7e5bc
fix: ci-deploy.sh no longer fights externally-owned dependency contai…
saqibmanan Sep 11, 2026
326d8d2
Merge pull request #164 from CivicDataLab/fix/ci-deploy-dependency-co…
saqibmanan Sep 11, 2026
2b8eb61
fix: attach backend/release to dataexchange_default network
saqibmanan Sep 11, 2026
c817bab
Merge pull request #166 from CivicDataLab/fix/backend-network-attachm…
saqibmanan Sep 11, 2026
8f5ff05
fix: commit 46 missing api/authorization migrations, was gitignored
saqibmanan Sep 11, 2026
cced8c6
Merge pull request #168 from CivicDataLab/fix/missing-publication-mig…
saqibmanan Sep 11, 2026
59d52a9
fix: point prod smoke tests at prod and skip provider-smoke there
saqibmanan Sep 11, 2026
0a363ba
Merge pull request #176 from CivicDataLab/fix/prod-smoke-targets-prod…
saqibmanan Sep 11, 2026
32d45d3
feat: two-stage dev smoke gate, readonly-only prod tests
saqibmanan Sep 11, 2026
067a53c
feat: full-suite PR gate against dev for PRs into main
saqibmanan Sep 11, 2026
3f78dc8
feat: run provider functional report-only while it is flaky against dev
saqibmanan Sep 15, 2026
c186cc8
Merge pull request #178 from CivicDataLab/feat/deploy-gating-model-dev
saqibmanan Sep 15, 2026
ac01ce4
fix: wait for every test before finalizing or rolling back
saqibmanan Sep 15, 2026
aa2c844
Merge pull request #180 from CivicDataLab/fix/finalize-waits-for-all-…
saqibmanan Sep 15, 2026
53d925b
fix: pass KEYCLOAK_CLIENT_SECRET to the report-only provider job
saqibmanan Sep 15, 2026
788b37d
fix: least-privilege permissions on pr-gate; pass Keycloak secret to …
saqibmanan Sep 15, 2026
f357491
Merge pull request #184 from CivicDataLab/fix/report-jobs-keycloak-se…
saqibmanan Sep 15, 2026
eaa5806
fix: require authentication on the user-info endpoint
saqibmanan Sep 16, 2026
cd2c2e1
fix: convert search hits to plain data before caching them
saqibmanan Sep 16, 2026
e494a29
test: cover the user-info 401 and the search-cache pickling
saqibmanan Sep 16, 2026
2f98c7d
Merge pull request #187 from CivicDataLab/fix/user-info-auth-and-sear…
saqibmanan Sep 16, 2026
3bca7a2
fix(tests): repair the syntax error that blocked test collection
saqibmanan Sep 16, 2026
4a6ed7e
ci: run the Django test suite on push, PR and in the regression gate
saqibmanan Sep 16, 2026
eaf5a1c
Merge pull request #190 from CivicDataLab/fix/user-info-auth-and-sear…
saqibmanan Sep 16, 2026
3bab067
Merge dev into main
saqibmanan Sep 16, 2026
8a5e107
Potential fix for pull request finding 'CodeQL / Workflow does not co…
saqibmanan Sep 16, 2026
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
46 changes: 46 additions & 0 deletions .github/workflows/django-tests.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
name: Django Tests

# The SDK workflows run a handful of tests with -p no:django, so nothing ever
# exercised the Django side: views, schema, permissions. This runs that suite.
on:
push:
branches: [dev, main]
pull_request:
branches: [dev, main]
workflow_call:

permissions:
contents: read

jobs:
django-tests:
runs-on: ubuntu-latest

steps:
- uses: actions/checkout@v4

- name: Set up Python
uses: actions/setup-python@v5
with:
python-version: "3.11"
cache: pip

- name: Install dependencies
run: |
python -m pip install --upgrade pip
pip install -r requirements.txt
# Not in requirements.txt, which covers the running service only.
pip install pytest pytest-django psycopg2-binary setuptools

- name: Prepare environment
run: |
cp .env.example .env
mkdir -p logs

# tests/object_types/charts and the dataset metadata tests were left
# behind by the chart consolidation and fail on main: see #189.
- name: Run tests
run: |
pytest tests/ \
--ignore=tests/object_types/charts \
--deselect tests/schema/test_dataset_schema.py::TestAddUpdateDatasetMetadata
Comment thread
github-advanced-security[bot] marked this conversation as resolved.
Fixed
5 changes: 5 additions & 0 deletions .github/workflows/pr-gate.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,11 @@ concurrency:
cancel-in-progress: true

jobs:
# Unit tests for this repo's own code, alongside the browser/API suite.
django-tests:
name: Django Tests
uses: ./.github/workflows/django-tests.yml

full-suite:
name: Full Suite (dev)
uses: CivicDataLab/CivicDataSpace-test/.github/workflows/run-smoke.yml@CI
Expand Down
7 changes: 6 additions & 1 deletion api/views/auth.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
from rest_framework import status, views
from rest_framework.permissions import AllowAny
from rest_framework.permissions import AllowAny, IsAuthenticated
from rest_framework.request import Request
from rest_framework.response import Response
from rest_framework_simplejwt.tokens import RefreshToken
Expand Down Expand Up @@ -83,6 +83,11 @@ class UserInfoView(views.APIView):
View for getting the current user's information.
"""

# Without this the project-wide AllowAny default lets an anonymous request
# through, and reading .email off AnonymousUser raises a 500 instead of
# returning 401.
permission_classes = [IsAuthenticated]

def get(self, request: Request) -> Response:
user = request.user
return Response(
Expand Down
32 changes: 27 additions & 5 deletions api/views/paginated_elastic_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,33 @@
from django.core.cache import cache
from django.http import HttpRequest, HttpResponse
from elasticsearch_dsl import Search
from elasticsearch_dsl.utils import AttrDict, AttrList
from rest_framework.permissions import AllowAny
from rest_framework.response import Response
from rest_framework.serializers import Serializer
from rest_framework.views import APIView

from api.signals.dataset_signals import SEARCH_CACHE_VERSION_KEY

def as_plain_data(value: Any) -> Any:
"""Convert Elasticsearch wrapper objects into plain Python containers.

Serialized hits keep AttrList/AttrDict/InnerDoc values for nested fields.
Those classes are rebuilt per document type, so pickling one raises
"it's not the same object as elasticsearch_dsl.document.InnerDoc" and the
cache write fails, turning the whole response into a 500.
"""
if isinstance(value, AttrList):
return [as_plain_data(item) for item in value]
if isinstance(value, AttrDict):
return as_plain_data(value.to_dict())
if isinstance(value, dict):
return {key: as_plain_data(item) for key, item in value.items()}
if isinstance(value, (list, tuple)):
return [as_plain_data(item) for item in value]
return value


T = TypeVar("T")
SearchType = TypeVar("SearchType", bound=Search)
SerializerType = TypeVar("SerializerType", bound=Serializer)
Expand Down Expand Up @@ -173,11 +193,13 @@ def get(self, request: HttpRequest) -> Response:
for agg in is_individual_usecase_agg:
aggregations["is_individual_usecase"][agg["key"]] = agg["doc_count"]

result: Dict[str, Any] = {
"results": serializer.data,
"total": response.hits.total.value, # type: ignore
"aggregations": aggregations,
}
result: Dict[str, Any] = as_plain_data(
{
"results": serializer.data,
"total": response.hits.total.value, # type: ignore
"aggregations": aggregations,
}
)

# Cache the result
cache.set(cache_key, result, timeout=3600) # Cache for 1 hour
Expand Down
15 changes: 5 additions & 10 deletions tests/object_types/charts/test_grouped_bar_chart.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,8 +65,7 @@ def test_vertical_grouped_bar_chart(self):
}
chart_details = MockResourceChartDetails(
chart_type="BAR",
options={**options, "allow_multi_series": true},
options=options,
options={**options, "allow_multi_series": True},
)
chart = UnifiedChart(chart_details, self.test_data)
result = chart.create_chart()
Expand Down Expand Up @@ -104,8 +103,7 @@ def test_styling_options(self):
}
chart_details = MockResourceChartDetails(
chart_type="BAR",
options={**options, "allow_multi_series": true},
options=options,
options={**options, "allow_multi_series": True},
)
chart = UnifiedChart(chart_details, self.test_data)
result = chart.create_chart()
Expand Down Expand Up @@ -136,8 +134,7 @@ def test_value_aggregation(self):
}
chart_details = MockResourceChartDetails(
chart_type="BAR",
options={**options, "allow_multi_series": true},
options=options,
options={**options, "allow_multi_series": True},
)
chart = UnifiedChart(chart_details, test_data)
result = chart.create_chart()
Expand All @@ -156,8 +153,7 @@ def test_time_based_grouped_bar_chart(self):
}
chart_details = MockResourceChartDetails(
chart_type="BAR",
options={**options, "allow_multi_series": true},
options=options,
options={**options, "allow_multi_series": True},
)
chart = UnifiedChart(chart_details, self.test_data)
result = chart.create_chart()
Expand Down Expand Up @@ -190,8 +186,7 @@ def test_value_mapping(self):
}
chart_details = MockResourceChartDetails(
chart_type="BAR",
options={**options, "allow_multi_series": true},
options=options,
options={**options, "allow_multi_series": True},
)
chart = UnifiedChart(chart_details, test_data)
result = chart.create_chart()
Expand Down
67 changes: 67 additions & 0 deletions tests/test_search_cache_and_user_info.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
"""Regression tests for the two 500s on /api/auth/user/info/ and /api/search/aimodel/."""

import pickle

import pytest
from elasticsearch_dsl import InnerDoc
from elasticsearch_dsl.utils import AttrDict, AttrList
from rest_framework.test import APIRequestFactory

from api.views.auth import UserInfoView
from api.views.paginated_elastic_view import as_plain_data


def nested_doc(**fields: object) -> InnerDoc:
"""Build a nested doc the way elasticsearch_dsl does for a nested field.

The class is rebuilt per document type rather than being the module-level
InnerDoc, which is exactly what pickle refuses to serialize.
"""
cls = type("InnerDoc", (InnerDoc,), {})
cls.__module__ = "elasticsearch_dsl.document"
doc = cls()
for name, value in fields.items():
setattr(doc, name, value)
return doc


@pytest.mark.django_db
def test_user_info_rejects_anonymous_request() -> None:
"""Anonymous callers get 401, not a 500 from reading .email off AnonymousUser.

The view is called directly: routing it through the test client hides the
bug, because the test settings drop the Keycloak middleware.
"""
request = APIRequestFactory().get("/api/auth/user/info/")
response = UserInfoView.as_view()(request)
assert response.status_code == 401


def test_as_plain_data_makes_search_results_picklable() -> None:
"""Nested hits must survive cache.set, which pickles the cached value."""
result = {
"results": [
{
"all_providers": AttrList([nested_doc(provider="GPT")]),
"name": AttrDict({"raw": "x"}),
}
],
"total": 1,
}

plain = as_plain_data(result)
assert pickle.loads(pickle.dumps(plain)) == plain
assert plain["results"][0]["all_providers"] == [{"provider": "GPT"}]
assert plain["results"][0]["name"] == {"raw": "x"}
assert not _holds_elastic_objects(plain)


def _holds_elastic_objects(value: object) -> bool:
"""The cache pickles what it is given, so no wrapper may survive anywhere."""
if isinstance(value, (AttrDict, AttrList, InnerDoc)):
return True
if isinstance(value, dict):
return any(_holds_elastic_objects(item) for item in value.values())
if isinstance(value, (list, tuple)):
return any(_holds_elastic_objects(item) for item in value)
return False
Loading