Skip to content

Commit d2ee49a

Browse files
committed
Address review: distinct missing-IOSettings error, frozen pk_fields default, obj_id regression test
- _require_io_setting now emits a distinct error when IOSettings itself is absent vs. when a single field is None - pk_fields default flipped from list[str]=[] to Sequence[str]=() to remove the shared-mutable-default footgun (subclass overrides with concrete lists are unchanged) - DRFDeletion.mutate gains a comment noting the intentional asymmetry: errors propagate raw rather than being swallowed by DRFMutation's except Exception - Consolidated the duplicated to_global_id explanation comment to a single block at the graphene_model resolution site - New test asserts CreateCorpus.objId decodes to "CorpusType" (not the metaclass name) — locks in the .__name__ vs .__class__.__name__ fix
1 parent 2d99661 commit d2ee49a

2 files changed

Lines changed: 59 additions & 5 deletions

File tree

config/graphql/base.py

Lines changed: 18 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import logging
33
import traceback
44
from abc import ABC
5+
from collections.abc import Sequence
56
from typing import Any, ClassVar, Optional
67

78
import django.db.models
@@ -25,7 +26,11 @@
2526
def _require_io_setting(mutation_cls: type, name: str) -> Any:
2627
"""Raise ``NotImplementedError`` if ``cls.IOSettings.<name>`` is missing or ``None``."""
2728
io_settings = getattr(mutation_cls, "IOSettings", None)
28-
value = getattr(io_settings, name, None) if io_settings is not None else None
29+
if io_settings is None:
30+
raise NotImplementedError(
31+
f"{mutation_cls.__name__} must define an IOSettings class."
32+
)
33+
value = getattr(io_settings, name, None)
2934
if value is None:
3035
raise NotImplementedError(
3136
f"{mutation_cls.__name__}.IOSettings.{name} must be set by the "
@@ -101,6 +106,11 @@ class Arguments:
101106
@graphql_ratelimit(rate=RateLimits.WRITE_LIGHT)
102107
def mutate(cls, root, info, *args, **kwargs) -> "DRFDeletion":
103108

109+
# Unlike ``DRFMutation.mutate`` below, this method intentionally does
110+
# NOT wrap the body in ``except Exception``. Errors (including the
111+
# ``NotImplementedError`` from ``_require_io_setting`` and the
112+
# ``ValueError`` for missing lookup args) propagate to the GraphQL
113+
# framework as raw execution errors.
104114
ok = False
105115

106116
model = _require_io_setting(cls, "model")
@@ -146,7 +156,9 @@ def mutate(cls, root, info, *args, **kwargs) -> "DRFDeletion":
146156

147157
class DRFMutation(graphene.Mutation):
148158
class IOSettings(ABC):
149-
pk_fields: ClassVar[list[str]] = []
159+
# Frozen default — subclasses override with their own list/tuple.
160+
# Using a tuple here avoids the shared-mutable-default footgun.
161+
pk_fields: ClassVar[Sequence[str]] = ()
150162
lookup_field: ClassVar[str] = "id"
151163
model: ClassVar[Optional[type[django.db.models.Model]]] = None
152164
graphene_model: ClassVar[Optional[type[DjangoObjectType]]] = None
@@ -200,6 +212,10 @@ def mutate(cls, root, info, *args, **kwargs) -> "DRFMutation":
200212
logger.info(f"DRFMutation - kwargs: {kwargs}")
201213
serializer = _require_io_setting(cls, "serializer")
202214
model = _require_io_setting(cls, "model")
215+
# ``graphene_model`` is the class itself; ``__name__`` is the GraphQL
216+
# type name (e.g. ``"CorpusType"``). ``__class__.__name__`` would
217+
# return the metaclass name (``"SubclassWithMeta_Meta"``) which is
218+
# the bug this PR fixes — kept dereferenced as ``__name__`` below.
203219
graphene_model = _require_io_setting(cls, "graphene_model")
204220

205221
if hasattr(cls.IOSettings, "pk_fields"):
@@ -262,7 +278,6 @@ def mutate(cls, root, info, *args, **kwargs) -> "DRFMutation":
262278
obj_serializer.save()
263279
ok = True
264280
message = "Success"
265-
# graphene_model is the class; .__name__ is the GraphQL type (.__class__.__name__ is the metaclass).
266281
obj_id = to_global_id(graphene_model.__name__, obj.id)
267282
logger.info("Succeeded updating obj")
268283

@@ -283,7 +298,6 @@ def mutate(cls, root, info, *args, **kwargs) -> "DRFMutation":
283298

284299
ok = True
285300
message = "Success"
286-
# graphene_model is the class; .__name__ is the GraphQL type (.__class__.__name__ is the metaclass).
287301
obj_id = to_global_id(graphene_model.__name__, obj.id)
288302

289303
except serializers.ValidationError as ve:

opencontractserver/tests/test_security_hardening.py

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1224,7 +1224,8 @@ class MisconfiguredMutation:
12241224
with self.assertRaises(NotImplementedError) as ctx:
12251225
_require_io_setting(MisconfiguredMutation, "model")
12261226
self.assertIn("MisconfiguredMutation", str(ctx.exception))
1227-
self.assertIn("model", str(ctx.exception))
1227+
# Distinct message for the missing-class case (vs. missing-field).
1228+
self.assertIn("IOSettings", str(ctx.exception))
12281229

12291230
def test_require_io_setting_raises_when_attribute_none(self):
12301231
"""Each of model/serializer/graphene_model must independently fail when ``None``."""
@@ -1276,10 +1277,49 @@ class IOSettings(DRFDeletion.IOSettings):
12761277
# ``@login_required`` from graphql_jwt looks for a ``ResolveInfo`` arg
12771278
# via ``isinstance``; spec the mock so the decorator passes through
12781279
# to the wrapped function where the real lookup-value check fires.
1280+
# This relies on ``@graphql_ratelimit`` being a no-op under test
1281+
# conditions (no real cache backend is consulted before the body).
12791282
info = MagicMock(spec=ResolveInfo)
12801283
info.context = MagicMock()
12811284
info.context.user = MagicMock(is_authenticated=True)
12821285

12831286
with self.assertRaises(ValueError) as ctx:
12841287
_DeleteCorpus.mutate(None, info)
12851288
self.assertIn("id", str(ctx.exception))
1289+
1290+
def test_drf_mutation_obj_id_uses_graphene_type_name_not_metaclass(self):
1291+
"""Regression: ``to_global_id`` must use ``graphene_model.__name__``
1292+
(the GraphQL type, e.g. ``"CorpusType"``), not
1293+
``graphene_model.__class__.__name__`` (the metaclass name like
1294+
``"SubclassWithMeta_Meta"``).
1295+
"""
1296+
from graphene.test import Client
1297+
from graphql_relay import from_global_id
1298+
1299+
from config.graphql.schema import schema
1300+
from opencontractserver.corpuses.models import Corpus
1301+
1302+
user = User.objects.create_user(username="objIdRegressionUser", password="x")
1303+
1304+
class _Ctx:
1305+
def __init__(self, user):
1306+
self.user = user
1307+
1308+
client = Client(schema, context_value=_Ctx(user))
1309+
result = client.execute("""
1310+
mutation {
1311+
createCorpus(title: "ObjIdRegression") {
1312+
ok
1313+
objId
1314+
}
1315+
}
1316+
""")
1317+
self.assertIsNone(result.get("errors"))
1318+
self.assertTrue(result["data"]["createCorpus"]["ok"])
1319+
1320+
obj_id = result["data"]["createCorpus"]["objId"]
1321+
type_name, pk = from_global_id(obj_id)
1322+
# The fix: ``__name__`` produces the graphene type name.
1323+
self.assertEqual(type_name, "CorpusType")
1324+
# Underlying row exists at that pk — proves the global id is decodable.
1325+
self.assertTrue(Corpus.objects.filter(pk=int(pk)).exists())

0 commit comments

Comments
 (0)