Skip to content

Commit 50ed674

Browse files
JSv4claude
andauthored
Fix User.__init__ mutating shared Field.validators list on every instantiation (#1374)
* Fix User.__init__ mutating shared Field.validators list on every instantiation Declare UserUnicodeUsernameValidator on the User.username field at class-body time instead of patching ``validators[0]`` inside ``User.__init__``. The old pattern mutated a field-level singleton list on every ``User(...)`` call (ORM hydration, form save, queryset materialisation, etc.), and the hard-coded index would silently regress to Django's default validator if any third-party package or future migration prepended its own validator. Adds migration 0026 to update the stored field definition and regression tests covering: - the validator is declared on the field (no instance-level patching); - usernames containing ``\``, ``|``, ``*`` still pass validation; - 100 back-to-back instantiations do not grow the validators list; - a third-party prepend does not clobber the OpenContracts validator. Closes #1358 * Apply black/pyupgrade formatting to #1358 fix - Make the module-level docstring in test_user_username_validator.py raw so embedded ``\`` escape sequences don't trigger W605. - Collapse the black-reformatted migration to the pyupgrade-canonical single-line dict. * Address review: fix help_text raw-string drift, tighten tests * Address PR #1374 review: add username_validator class attribute Django admin forms (UserChangeForm, UserCreationForm) reference model.username_validator directly, separate from Field.validators. Without this attribute, admins editing usernames containing \, |, * would hit form-level rejection from the inherited UnicodeUsernameValidator even though the field itself accepts them. --------- Signed-off-by: JSIV <5049984+JSv4@users.noreply.github.com> Co-authored-by: Claude <noreply@anthropic.com>
1 parent fb62757 commit 50ed674

4 files changed

Lines changed: 129 additions & 5 deletions

File tree

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
5555
- **Keyboard navigation in CAML extract picker**: Added a listbox keyboard handler to the "Insert Extract Grid" dropdown in `CamlArticleEditor` — Arrow keys / Home / End move the focused option, Enter selects, Escape closes and returns focus to the trigger button. Active option is reflected via `aria-activedescendant` and an `$active` highlight on `ExtractPickerItem`.
5656
- **`buildSourceLink` page-indexing convention documented**: Added an explanatory comment clarifying that `Annotation.page` is 1-based (model default=1) and is only used for the chip label (`p.{page}`) — the document viewer navigates by `annotationId` alone, so there is no URL-layer indexing convention to worry about (`frontend/src/components/extracts/ExtractGridEmbed.tsx`).
5757
- **Verified `GET_EXTRACTS` already selects `fullDocumentList`** (`frontend/src/graphql/queries.ts:1901`) and that `CamlDirectiveRenderer` still wires `resolveImageSrc` through to `MarkdownMessageRenderer` — both no-ops requested by the issue's verification items.
58+
- **`User.__init__` mutated the shared `Field.validators` list on every instantiation** (Issue #1358, `opencontractserver/users/models.py:150-153`): `User.__init__` overwrote `self._meta.get_field("username").validators[0]` with a fresh `UserUnicodeUsernameValidator()` on every `User(...)` construction — including every ORM hydration, form save, and QuerySet materialisation. Two problems: (1) per-instance allocation + assignment on a hot path, and (2) a hard-coded `validators[0]` index that would silently flip the slot back to Django's default `UnicodeUsernameValidator` if any third-party code or future migration prepended a validator, breaking usernames that rely on `\` or `|`. Fix: declare the validator on the `username` field at class-body time (`opencontractserver/users/models.py:78-90`) and drop the `__init__` override. Added migration `0026_alter_user_username_validator.py` to update the stored field definition. Regression tests in `opencontractserver/tests/test_user_username_validator.py` verify the validator survives repeated instantiation and continues to accept `\`, `|`, `*`.
5859
- **`RemoveLabelsFromLabelsetMutation` silently did nothing** (Issue #1359, `config/graphql/label_mutations.py:296`): The resolver referenced `labelset.documents.filter(pk__in=label_pks)`, but `LabelSet` has no `documents` relation — the M2M to labels is `annotation_labels` (see `opencontractserver/annotations/models.py:1284`). Every invocation therefore raised `AttributeError`, which was swallowed by the surrounding `except Exception as e:` block and returned as a generic `"Error removing label(s) from labelset: ..."` with `ok=False`. Because the frontend `REMOVE_ANNOTATION_LABELS_FROM_LABELSET` mutation was itself unused (#1244 swept it out), this bug went unnoticed in production for an unknown length of time; discovered while grading mypy errors for #1332 (`config/graphql/label_mutations.py:296: error: "LabelSet" has no attribute "documents" [attr-defined]`). Swapped `documents` → `annotation_labels`, removed the now-resolved error from `docs/typing/mypy_baseline.txt`, and added `opencontractserver/tests/test_label_mutations.py` with four regression cases covering: labels are actually removed from the M2M, IDs not in the labelset are silently ignored, a non-owner / non-public caller cannot mutate the labelset, and a public labelset remains editable (pinning the current `Q(creator=user) | Q(is_public=True)` resolver behaviour so any future permission hardening is explicit).
5960
- **`package_annotated_docs` silently corrupted exports when a document failed to burn** (Issue #1356, `opencontractserver/tasks/export_tasks.py:150-212`, `opencontractserver/utils/etl.py:198-463`, `opencontractserver/tasks/doc_tasks.py:463-504`): `build_document_export()` returns `("", "", None, {}, {})` when a per-document export fails (e.g. the underlying file cannot be loaded). The V1 consumer `package_annotated_docs` had no guard for that placeholder — it ran `doc[1].encode("utf-8")` on the empty string (harmless, no crash), wrote an empty-named entry into the zip, and inserted `annotated_docs[""] = None` into the final `data.json`, so a single failed document silently poisoned the export. The V2 pipeline in `export_tasks_v2.py:126-128` already has this guard; V1 did not. Added `if not doc_name or doc_export is None: continue` (mirroring V2's check) and logged a warning identifying the skipped doc. Also tightened the return-type annotations on `build_document_export` and `burn_doc_annotations`: slots 0 and 1 are always `str` (never `None`; they are empty strings on the failure path), so the signature is now `tuple[str, str, OpenContractDocExport | None, dict[...], dict[...]]`. Corrected the `burned_docs` parameter annotation on `package_annotated_docs` from a single-element tuple-of-tuples to a variadic `tuple[tuple[...], ...]` — the runtime iteration is variadic, and the previous annotation was a red herring uncovered while typing for #1334. Regression test in `opencontractserver/tests/test_package_annotated_docs.py` covers both the mixed-success and all-failed scenarios, asserting the zip contains no empty-named entries and `annotated_docs` holds no `None` values.
6061
- **Frontend coverage badge stuck on "unknown"** (`README.md:12`, `.github/workflows/codecov-notify.yml`, `.github/workflows/frontend.yml`, `.github/workflows/frontend-e2e.yml`, `frontend/package.json`, `frontend/yarn.lock`): PR #1322 pointed the README badge at a new merged `frontend` flag fed by a cross-workflow `lcov-result-merger@5` step inside `codecov-notify.yml`. Every `frontend-merged-coverage` upload since has landed at Codecov with `state: error` / `totals: null`, so the badge rendered "unknown" even though `frontend-unit` (31%), `frontend-component` (61%), and `frontend-e2e` (24%) were all processing correctly. Two defects in the merged lcov confirmed by local repro of the CI merge step: (1) `lcov-result-merger@5` emits a stripped lcov containing only `SF:`, `DA:`, `BRDA:`, `end_of_record` — it drops `TN:`, `FN`, `FNDA`, `FNF`, `FNH`, `LF`, `LH`, `BRF`, `BRH`, so line-summary fields required by Codecov's parser are absent; (2) Vitest v8 emits `src/...` (relative to `frontend/`) while `vite-plugin-istanbul` + `nyc report` emit `/home/runner/work/OpenContracts/OpenContracts/frontend/src/...` (absolute), and the merger keys on the literal path string so the same file appears as two records with conflicting hit counts. `codecov-notify.yml` also ran without `actions/checkout`, which Codecov's action docs explicitly recommend against. Fix: stop merging client-side and let Codecov aggregate server-side, since that is what flags are for. Each per-suite upload now declares two flags — `frontend-unit,frontend`, `frontend-component,frontend`, `frontend-e2e,frontend` — so the `frontend` flag total is the union of the three uploads computed by Codecov. `codecov-notify.yml` is reduced to its original gate-and-notify role (no artifact downloads, no `lcov-result-merger`, no merged upload). Deleted the `frontend-{unit,ct,e2e}-lcov` artifact publishes in `frontend.yml` / `frontend-e2e.yml`, removed the `lcov-result-merger@^5.0.1` devDep and the `coverage:merge` script from `frontend/package.json`, and pruned the orphaned entries from `frontend/yarn.lock`. README badge URL unchanged (`flag=frontend`).
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,78 @@
1+
"""Regression tests for shared-state mutation in ``User.username`` validators."""
2+
3+
from django.contrib.auth import get_user_model
4+
from django.test import TestCase
5+
6+
from opencontractserver.users.validators import UserUnicodeUsernameValidator
7+
8+
User = get_user_model()
9+
10+
11+
class UsernameValidatorRegressionTests(TestCase):
12+
"""Guardrails against shared-state mutation of ``User.username`` validators."""
13+
14+
def test_username_field_has_opencontracts_validator(self):
15+
"""The custom validator must be declared on the field, not patched in."""
16+
field = User._meta.get_field("username")
17+
self.assertTrue(
18+
any(isinstance(v, UserUnicodeUsernameValidator) for v in field.validators),
19+
"Expected UserUnicodeUsernameValidator to be declared on User.username",
20+
)
21+
22+
def test_permissive_characters_accepted(self):
23+
"""Usernames containing ``\\``, ``|``, and ``*`` must pass validation."""
24+
permissive_username = r"name\with|pipes*and-slash"
25+
user = User(username=permissive_username, email="perm@example.com")
26+
# full_clean() runs every validator on the field; should not raise.
27+
user.full_clean(exclude=["password"])
28+
29+
def test_validators_list_stable_across_instantiations(self):
30+
"""Creating many ``User`` instances must not grow/shrink the validators list."""
31+
field = User._meta.get_field("username")
32+
baseline = list(field.validators)
33+
baseline_len = len(baseline)
34+
35+
for i in range(100):
36+
User(username=f"stable_user_{i}", email=f"stable{i}@example.com")
37+
38+
self.assertEqual(
39+
len(field.validators),
40+
baseline_len,
41+
"User instantiation mutated the shared Field.validators list.",
42+
)
43+
# Identity of each validator should also be preserved — we should not be
44+
# rebinding ``validators[0]`` on every ``User(...)`` call.
45+
for before, after in zip(baseline, field.validators):
46+
self.assertIs(
47+
before,
48+
after,
49+
"A validator on User.username was replaced during instantiation.",
50+
)
51+
52+
def test_third_party_prepend_does_not_corrupt_username_validator(self):
53+
"""A third-party validator prepended to ``Field.validators`` must survive ``User`` instantiation."""
54+
from django.core.validators import RegexValidator
55+
56+
field = User._meta.get_field("username")
57+
sentinel = RegexValidator(regex=r".*", message="sentinel")
58+
field.validators.insert(0, sentinel)
59+
try:
60+
# Instantiate a few users — previously this would reassign
61+
# ``validators[0]`` and overwrite the sentinel.
62+
for i in range(5):
63+
User(username=f"corrupt_check_{i}")
64+
65+
self.assertIs(
66+
field.validators[0],
67+
sentinel,
68+
"User.__init__ should not mutate Field.validators",
69+
)
70+
self.assertTrue(
71+
any(
72+
isinstance(v, UserUnicodeUsernameValidator)
73+
for v in field.validators
74+
),
75+
"UserUnicodeUsernameValidator must remain in the validators list",
76+
)
77+
finally:
78+
field.validators.remove(sentinel)
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from django.db import migrations, models
2+
3+
import opencontractserver.users.validators
4+
5+
6+
class Migration(migrations.Migration):
7+
"""Declare ``UserUnicodeUsernameValidator`` on ``User.username`` at the model layer."""
8+
9+
dependencies = [
10+
("users", "0025_alter_userexport_format_add_v2"),
11+
]
12+
13+
operations = [
14+
migrations.AlterField(
15+
model_name="user",
16+
name="username",
17+
field=models.CharField(
18+
error_messages={"unique": "A user with that username already exists."},
19+
help_text=(
20+
"Required. 150 characters or fewer. Letters, digits and "
21+
"@/./+/-/_/|/*/\\ only."
22+
),
23+
max_length=150,
24+
unique=True,
25+
validators=[
26+
opencontractserver.users.validators.UserUnicodeUsernameValidator()
27+
],
28+
verbose_name="username",
29+
),
30+
),
31+
]

opencontractserver/users/models.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -70,6 +70,25 @@ def visible_to_user(self, user=None):
7070
class User(AbstractUser):
7171
"""Default user for OpenContractServer."""
7272

73+
# Class attribute — referenced by Django admin forms (UserChangeForm,
74+
# UserCreationForm) directly, separate from the field validators list.
75+
username_validator = UserUnicodeUsernameValidator()
76+
77+
# Declared at class body to avoid mutating the shared Field.validators list on every User() call.
78+
username = django.db.models.CharField(
79+
_("username"),
80+
max_length=150,
81+
unique=True,
82+
help_text=_(
83+
"Required. 150 characters or fewer. Letters, digits and "
84+
"@/./+/-/_/|/*/\\ only."
85+
),
86+
validators=[UserUnicodeUsernameValidator()],
87+
error_messages={
88+
"unique": _("A user with that username already exists."),
89+
},
90+
)
91+
7392
#: First and last name do not cover name patterns around the globe
7493
name = django.db.models.CharField(_("Name of User"), blank=True, max_length=255)
7594
first_name = django.db.models.CharField("First Name", blank=True, max_length=255)
@@ -147,11 +166,6 @@ class User(AbstractUser):
147166
def __str__(self):
148167
return f"{self.username}: {self.email}"
149168

150-
def __init__(self, *args, **kwargs):
151-
self._meta.get_field("username").validators[0] = UserUnicodeUsernameValidator()
152-
153-
super().__init__(*args, **kwargs)
154-
155169
def get_absolute_url(self):
156170
"""Get url for user's detail view.
157171

0 commit comments

Comments
 (0)