-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_autocomplete.py
More file actions
181 lines (145 loc) · 6.9 KB
/
test_autocomplete.py
File metadata and controls
181 lines (145 loc) · 6.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
"""Tests for ``GET /api/v1/<app>/<model>/autocomplete/`` (Issue #59).
Mandatory matrix (per CLAUDE.md §6):
- Anonymous → 403 / no body leak.
- Authenticated non-staff → 403.
- Staff without view permission on the target → 403.
- Staff with view permission → 200 + results.
- Unregistered model → 404.
- Target admin without ``search_fields`` → 400.
Plus feature-specific tests: search delegation, page pagination,
``has_more`` flag, page_size clamp.
"""
from __future__ import annotations
from contextlib import contextmanager
from contextlib import suppress
import pytest
from django.contrib import admin
from django.contrib.auth import get_user_model
from django.test import Client
from tests.helpers import admin_override
AUTOCOMPLETE_URL = "/admin-react/api/v1/auth/user/autocomplete/"
@contextmanager
def admin_attr(model_cls, **values):
"""Temporarily set non-callable attrs on a registered ModelAdmin."""
model_admin = admin.site._registry[model_cls]
sentinel = object()
originals: dict = {}
try:
for name, value in values.items():
originals[name] = model_admin.__dict__.get(name, sentinel)
setattr(model_admin, name, value)
yield
finally:
for name, original in originals.items():
if original is sentinel:
with suppress(AttributeError):
delattr(model_admin, name)
else:
setattr(model_admin, name, original)
# --------------------------------------------------------------------------- #
# §6 mandatory matrix #
# --------------------------------------------------------------------------- #
@pytest.mark.django_db
def test_anonymous_unauthorized(anon_client: Client) -> None:
response = anon_client.get(AUTOCOMPLETE_URL)
assert response.status_code == 403
body = response.content.decode("utf-8", errors="replace")
assert "password" not in body.lower()
@pytest.mark.django_db
def test_authenticated_non_staff_forbidden(user_client: Client) -> None:
response = user_client.get(AUTOCOMPLETE_URL)
assert response.status_code == 403
@pytest.mark.django_db
def test_staff_without_view_permission_returns_404(staff_client: Client) -> None:
"""Same posture as the list endpoint — unviewable model is 404, not 403,
so the endpoint doesn't reveal "this model exists but you can't see it"."""
User = get_user_model()
with admin_override(User, has_view_permission=lambda self, request, obj=None: False):
response = staff_client.get(AUTOCOMPLETE_URL)
assert response.status_code == 404
@pytest.mark.django_db
def test_unregistered_model_404(superuser_client: Client) -> None:
response = superuser_client.get("/admin-react/api/v1/unknown/nothing/autocomplete/")
assert response.status_code == 404
@pytest.mark.django_db
def test_admin_without_search_fields_returns_400(superuser_client: Client) -> None:
"""An admin with empty ``search_fields`` → 400.
The 400 surfaces the same condition Django admin itself raises
via ``ImproperlyConfigured`` on the HTML autocomplete view.
"""
User = get_user_model()
with admin_attr(User, search_fields=()):
response = superuser_client.get(AUTOCOMPLETE_URL)
assert response.status_code == 400
body = response.json()
assert body["error"]["code"] == "bad_request"
assert "search_fields" in body["error"]["message"]
# --------------------------------------------------------------------------- #
# Happy path + behavior #
# --------------------------------------------------------------------------- #
@pytest.mark.django_db
def test_returns_results_with_label_and_id(superuser_client: Client) -> None:
"""Successful autocomplete returns ``{results: [{id, label}], pagination}``."""
User = get_user_model()
User.objects.create_user(username="alice", password="x") # noqa: S106
User.objects.create_user(username="bob", password="x") # noqa: S106
with admin_attr(User, search_fields=("username",)):
response = superuser_client.get(AUTOCOMPLETE_URL)
assert response.status_code == 200
body = response.json()
assert "results" in body
assert "pagination" in body
for row in body["results"]:
assert set(row.keys()) == {"id", "label"}
assert isinstance(row["id"], int)
assert isinstance(row["label"], str)
@pytest.mark.django_db
def test_q_param_filters(superuser_client: Client) -> None:
"""``?q=al`` returns only rows the admin's search would return."""
User = get_user_model()
User.objects.create_user(username="alice", password="x") # noqa: S106
User.objects.create_user(username="bob", password="x") # noqa: S106
User.objects.create_user(username="alfred", password="x") # noqa: S106
with admin_attr(User, search_fields=("username",)):
response = superuser_client.get(AUTOCOMPLETE_URL + "?q=al")
body = response.json()
labels = {row["label"] for row in body["results"]}
assert "alice" in labels
assert "alfred" in labels
assert "bob" not in labels
@pytest.mark.django_db
def test_has_more_flag_signals_more_rows(superuser_client: Client) -> None:
"""``has_more=True`` when the queryset has at least one row past page_size."""
User = get_user_model()
for i in range(5):
User.objects.create_user(username=f"u{i}", password="x") # noqa: S106
with admin_attr(User, search_fields=("username",)):
response = superuser_client.get(AUTOCOMPLETE_URL + "?page_size=2")
body = response.json()
assert len(body["results"]) == 2
assert body["pagination"]["has_more"] is True
assert body["pagination"]["page"] == 1
assert body["pagination"]["page_size"] == 2
@pytest.mark.django_db
def test_page_size_clamped_to_autocomplete_max(superuser_client: Client) -> None:
"""Hostile ``?page_size=10000`` silently clamps to the autocomplete max (50)."""
User = get_user_model()
with admin_attr(User, search_fields=("username",)):
response = superuser_client.get(AUTOCOMPLETE_URL + "?page_size=10000")
body = response.json()
assert body["pagination"]["page_size"] <= 50
@pytest.mark.django_db
def test_garbage_page_param_defaults_to_one(superuser_client: Client) -> None:
"""``?page=abc`` must not 500 — falls back to page 1."""
User = get_user_model()
with admin_attr(User, search_fields=("username",)):
response = superuser_client.get(AUTOCOMPLETE_URL + "?page=abc")
assert response.status_code == 200
assert response.json()["pagination"]["page"] == 1
@pytest.mark.django_db
def test_cache_control_no_store(superuser_client: Client) -> None:
"""Per-user, search-term-specific payload must never be cached."""
User = get_user_model()
with admin_attr(User, search_fields=("username",)):
response = superuser_client.get(AUTOCOMPLETE_URL)
assert response["Cache-Control"] == "no-store"