-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathtest_spa_index.py
More file actions
263 lines (218 loc) · 10.5 KB
/
test_spa_index.py
File metadata and controls
263 lines (218 loc) · 10.5 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
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
"""Tests for the SPA index view at the package's mount URL.
The view serves the React shell HTML to staff. It is the only
non-API view in the package, and it enforces the same auth gate
the API uses (rule 1 in ``SECURITY.md`` §3).
"""
from __future__ import annotations
import json
from pathlib import Path
from unittest import mock
from urllib.parse import parse_qs
from urllib.parse import urlsplit
import pytest
from django.test import Client
import django_admin_react.views as views_module
from django_admin_react import views
ROOT_URL = "/admin-react/"
@pytest.fixture(autouse=True)
def _clear_manifest_cache() -> None:
"""The view caches the manifest in-process; isolate each test."""
views._load_manifest_entry.cache_clear() # type: ignore[attr-defined]
@pytest.fixture
def fake_manifest(tmp_path: Path) -> Path:
"""Drop a fake Vite manifest into the package's static dir for the test.
Uses ``mock.patch`` on the module-level path constant so we
don't actually write into the shipped package on disk.
"""
manifest_body = {
"index.html": {
"file": "assets/index-abc.js",
"name": "index",
"isEntry": True,
"css": ["assets/index-abc.css"],
}
}
manifest_path = tmp_path / "manifest.json"
manifest_path.write_text(json.dumps(manifest_body))
with mock.patch.object(views_module, "_MANIFEST_PATH", manifest_path):
views._load_manifest_entry.cache_clear() # type: ignore[attr-defined]
yield manifest_path
views._load_manifest_entry.cache_clear() # type: ignore[attr-defined]
# --------------------------------------------------------------------------- #
# Auth gate #
# --------------------------------------------------------------------------- #
@pytest.mark.django_db
def test_anonymous_user_redirected_to_login(anon_client: Client) -> None:
response = anon_client.get(ROOT_URL)
assert response.status_code == 302
# The package leaves LOGIN_URL up to the consumer's settings — only
# assert that the redirect carries the SPA path as the ``next``
# parameter so the user lands back here after login. The ``next``
# value is percent-encoded (CodeQL py/url-redirection fix), so the
# raw path appears encoded in Location; decode the query to compare.
location = response["Location"]
assert "next=" in location
query = parse_qs(urlsplit(location).query)
assert query["next"][0].startswith(ROOT_URL)
@pytest.mark.django_db
def test_authenticated_non_staff_redirected(user_client: Client) -> None:
"""Non-staff users do not see the SPA, even if logged in."""
response = user_client.get(ROOT_URL)
# The package treats them like anonymous for the SPA — same redirect
# path. (The API returns 403, but the SPA shell is a UI surface and
# bouncing through login is the friendlier flow.)
assert response.status_code == 302
assert "next=" in response["Location"]
@pytest.mark.django_db
def test_superuser_receives_spa_html(superuser_client: Client) -> None:
response = superuser_client.get(ROOT_URL)
assert response.status_code == 200
body = response.content.decode("utf-8")
assert '<div id="root">' in body
# CSRF cookie must be set so the SPA can read it before unsafe calls.
assert "csrftoken" in response.cookies
# --------------------------------------------------------------------------- #
# Mount detection #
# --------------------------------------------------------------------------- #
@pytest.mark.django_db
def test_mount_meta_tag_reflects_url(superuser_client: Client) -> None:
response = superuser_client.get(ROOT_URL)
assert response.status_code == 200
body = response.content.decode("utf-8")
assert 'name="dar-mount"' in body
assert 'content="/admin-react/"' in body
# --------------------------------------------------------------------------- #
# Bundle wiring #
# --------------------------------------------------------------------------- #
@pytest.mark.django_db
def test_with_manifest_includes_bundle_tags(superuser_client: Client, fake_manifest: Path) -> None:
response = superuser_client.get(ROOT_URL)
assert response.status_code == 200
body = response.content.decode("utf-8")
assert "assets/index-abc.js" in body
assert "assets/index-abc.css" in body
assert 'rel="stylesheet"' in body
assert 'type="module"' in body
@pytest.mark.django_db
def test_without_manifest_renders_helpful_fallback(
superuser_client: Client, tmp_path: Path
) -> None:
"""Point the view at a non-existent manifest to force the dev fallback."""
missing = tmp_path / "missing-manifest.json"
with mock.patch.object(views_module, "_MANIFEST_PATH", missing):
views._load_manifest_entry.cache_clear() # type: ignore[attr-defined]
response = superuser_client.get(ROOT_URL)
assert response.status_code == 200
body = response.content.decode("utf-8")
# The "build the SPA" instruction must be visible so a contributor
# who runs `runserver` without `pnpm build:vite` knows what to do.
assert "pnpm" in body
assert "build:vite" in body
# --------------------------------------------------------------------------- #
# Branding (BRAND_TITLE + BRAND_LOGO_URL) #
# --------------------------------------------------------------------------- #
import importlib
import pytest
from django.contrib.admin import site as default_admin_site
from django.test import override_settings
from django_admin_react import views as spa_views
def _reload_conf() -> None:
"""Force `django_admin_react.conf` to re-read settings."""
import django_admin_react.conf as _conf
importlib.reload(_conf)
# SpaIndexView holds a module-level reference to conf — re-bind.
importlib.reload(spa_views)
@pytest.mark.django_db
def test_brand_title_falls_back_to_admin_site_header(superuser_client: Client) -> None:
"""When `BRAND_TITLE` is unset, the SPA shell uses the configured
AdminSite's `site_header`. Consumers who already customised their
AdminSite for the legacy admin don't need a second setting.
"""
with override_settings(DJANGO_ADMIN_REACT={}):
_reload_conf()
original_header = default_admin_site.site_header
default_admin_site.site_header = "Operations Console"
try:
response = superuser_client.get(ROOT_URL)
html = response.content.decode("utf-8")
assert 'name="dar-brand-title" content="Operations Console"' in html
assert "<title>Operations Console</title>" in html
finally:
default_admin_site.site_header = original_header
@pytest.mark.django_db
def test_brand_title_explicit_override_wins(superuser_client: Client) -> None:
"""`BRAND_TITLE` overrides the AdminSite's `site_header`."""
with override_settings(DJANGO_ADMIN_REACT={"BRAND_TITLE": "Acme"}):
_reload_conf()
original_header = default_admin_site.site_header
default_admin_site.site_header = "Something Else"
try:
response = superuser_client.get(ROOT_URL)
html = response.content.decode("utf-8")
assert 'name="dar-brand-title" content="Acme"' in html
assert "<title>Acme</title>" in html
finally:
default_admin_site.site_header = original_header
@pytest.mark.django_db
def test_brand_logo_url_renders_favicon_and_meta(superuser_client: Client) -> None:
"""`BRAND_LOGO_URL` populates both the `<link rel="icon">` and the
`dar-brand-logo` meta tag the SPA reads at boot.
"""
logo_url = "/static/acme/logo.svg"
with override_settings(DJANGO_ADMIN_REACT={"BRAND_LOGO_URL": logo_url}):
_reload_conf()
response = superuser_client.get(ROOT_URL)
html = response.content.decode("utf-8")
assert f'name="dar-brand-logo" content="{logo_url}"' in html
assert f'rel="icon" href="{logo_url}"' in html
@pytest.mark.django_db
def test_brand_logo_url_unset_falls_back_to_data_uri(superuser_client: Client) -> None:
"""When `BRAND_LOGO_URL` is unset, the no-op `data:,` placeholder
is preserved (matches the prior hardcoded behaviour)."""
with override_settings(DJANGO_ADMIN_REACT={}):
_reload_conf()
response = superuser_client.get(ROOT_URL)
html = response.content.decode("utf-8")
assert 'rel="icon" href="data:,"' in html
assert 'name="dar-brand-logo"' not in html
# --------------------------------------------------------------------------- #
# REACT_LOGIN — serve the shell to anonymous users (Issue #167) #
# --------------------------------------------------------------------------- #
def test_react_login_off_anon_still_redirected(anon_client: Client) -> None:
"""Default (REACT_LOGIN unset): anonymous → 302 to the login page."""
with override_settings(DJANGO_ADMIN_REACT={}):
_reload_conf()
try:
response = anon_client.get(ROOT_URL)
assert response.status_code == 302
finally:
_reload_conf()
def test_react_login_on_anon_gets_shell_not_redirect(
anon_client: Client, fake_manifest: Path
) -> None:
"""REACT_LOGIN=True: anonymous gets the SPA shell (200) + CSRF cookie.
The React app then renders its own login form (Issue #167). The
shell carries no user data, so serving it to an anonymous user is
safe — every data API call still 403s until they authenticate.
"""
with override_settings(DJANGO_ADMIN_REACT={"REACT_LOGIN": True}):
_reload_conf()
try:
response = anon_client.get(ROOT_URL)
assert response.status_code == 200
# CSRF cookie issued so the login POST can carry X-CSRFToken.
assert "csrftoken" in response.cookies
# The shell must not leak any authenticated-user data.
body = response.content.decode("utf-8", errors="replace").lower()
assert "is_superuser" not in body
finally:
_reload_conf()
def test_react_login_on_does_not_change_staff_path(superuser_client: Client) -> None:
"""REACT_LOGIN=True doesn't alter the authenticated-staff behavior."""
with override_settings(DJANGO_ADMIN_REACT={"REACT_LOGIN": True}):
_reload_conf()
try:
response = superuser_client.get(ROOT_URL)
assert response.status_code == 200
finally:
_reload_conf()