Skip to content

Commit 97ab279

Browse files
🏗️ Correction du mode iframe : ajout tests, meilleure persistance (#1764)
1 parent 880de9e commit 97ab279

File tree

12 files changed

+169
-87
lines changed

12 files changed

+169
-87
lines changed

.secrets.baseline

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,10 @@
9090
{
9191
"path": "detect_secrets.filters.allowlist.is_line_allowlisted"
9292
},
93+
{
94+
"path": "detect_secrets.filters.common.is_baseline_file",
95+
"filename": ".secrets.baseline"
96+
},
9397
{
9498
"path": "detect_secrets.filters.common.is_ignored_due_to_verification_policies",
9599
"min_level": 2
@@ -138,7 +142,7 @@
138142
"filename": "core/settings.py",
139143
"hashed_secret": "1ee34e26aeaf89c64ecc2c85efe6a961b75a50e9",
140144
"is_verified": false,
141-
"line_number": 295
145+
"line_number": 296
142146
}
143147
],
144148
"dbt/package-lock.yml": [
@@ -187,5 +191,5 @@
187191
}
188192
]
189193
},
190-
"generated_at": "2025-07-02T08:21:38Z"
194+
"generated_at": "2025-07-03T13:57:54Z"
191195
}

core/context_processors.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,9 +26,9 @@ def content(request):
2626

2727
def global_context(request) -> dict:
2828
base = {
29+
"iframe": getattr(request, "iframe", False),
2930
"assistant": {
3031
"is_home": request.path == reverse("home"),
31-
"is_iframe": "iframe" in request.GET,
3232
"POSTHOG_KEY": settings.ASSISTANT["POSTHOG_KEY"],
3333
"MATOMO_ID": settings.ASSISTANT["MATOMO_ID"],
3434
"BASE_URL": settings.ASSISTANT["BASE_URL"],

core/settings.py

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -174,13 +174,17 @@
174174
with suppress(ModuleNotFoundError):
175175
from debug_toolbar.settings import CONFIG_DEFAULTS
176176

177+
def show_toolbar_callback(request):
178+
path_is_not_excluded = not any(p in request.path for p in patterns_to_exclude)
179+
# view_is_not_in_iframe_mode = "iframe" not in request.GET
180+
return path_is_not_excluded
181+
177182
patterns_to_exclude = [
178183
"/test_iframe",
184+
"/lookbook",
179185
]
180186
DEBUG_TOOLBAR_CONFIG = {
181-
"SHOW_TOOLBAR_CALLBACK": lambda request: not any(
182-
p in request.path for p in patterns_to_exclude
183-
),
187+
"SHOW_TOOLBAR_CALLBACK": show_toolbar_callback,
184188
"HIDE_IN_STACKTRACES": CONFIG_DEFAULTS["HIDE_IN_STACKTRACES"] + ("sentry_sdk",),
185189
}
186190

@@ -256,9 +260,6 @@ def context_processors():
256260
"sites_faciles.content_manager.context_processors.skiplinks",
257261
"sites_faciles.content_manager.context_processors.mega_menus",
258262
],
259-
"builtins": [
260-
"core.templatetags.iframe_tags",
261-
],
262263
},
263264
},
264265
]
@@ -518,5 +519,5 @@ def context_processors():
518519
# ---
519520
LOOKBOOK = {
520521
"preview_base": ["previews"],
521-
"show_previews": DEBUG,
522+
"show_previews": decouple.config("SHOE_PREVIEWS", default=True, cast=bool),
522523
}

core/templatetags/iframe_tags.py

Lines changed: 0 additions & 38 deletions
This file was deleted.

e2e_tests/iframe.spec.ts

Lines changed: 28 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { expect } from "@playwright/test"
22
import { test } from "./config"
3+
import { hideDjangoToolbar } from "./helpers"
34

45
test("Desktop | iframe formulaire is loaded with correct parameters", async ({
56
page,
@@ -116,7 +117,7 @@ test("Desktop | iframe cannot read the referrer when referrerPolicy is set to no
116117
expect(referrer).toBe("")
117118
})
118119

119-
test("iframe can read the referrer when referrerPolicy is not set", async ({
120+
test("Desktop | iframe can read the referrer when referrerPolicy is not set", async ({
120121
page,
121122
assistantUrl,
122123
}) => {
@@ -136,22 +137,29 @@ test("iframe can read the referrer when referrerPolicy is not set", async ({
136137
expect(referrer).toBe(`${assistantUrl}/test_iframe?carte=1`)
137138
})
138139

139-
// Need to be run locally with nginx running
140-
// test("Desktop | iframe mode is kept during navigation", async ({ browser, page, carteUrl, assistantUrl }) => {
141-
// await page.goto(`/dechet/chaussures?iframe`, { waitUntil: "domcontentloaded" });
142-
// page.getByTestId("header-logo-link").click()
143-
// await expect(page).toHaveURL(`${assistantUrl}`)
144-
// expect(await page.$("body > footer")).toBeFalsy()
145-
// await page.close()
146-
147-
// const newPage = await browser.newPage()
148-
// await newPage.goto(`/dechet/chaussures`, { waitUntil: "networkidle" });
149-
// expect(browser.contexts)
150-
// expect(await newPage.$("body > footer")).toBeTruthy()
151-
// await newPage.close()
152-
// const yetAnotherPage = await browser.newPage()
153-
// await yetAnotherPage.goto(`/dechet/chaussures?iframe`, { waitUntil: "networkidle" });
154-
// await yetAnotherPage.goto(`/`, { waitUntil: "networkidle" });
155-
// await yetAnotherPage.goto(`/dechet/chaussures`, { waitUntil: "networkidle" });
156-
// expect(await yetAnotherPage.$("body > footer")).not.toBeTruthy()
157-
// });
140+
test("Desktop | iFrame mode persists across navigation", async ({
141+
page,
142+
assistantUrl,
143+
}) => {
144+
test.slow()
145+
// Starting URL - change this to your site's starting point
146+
await page.goto(`${assistantUrl}/?iframe`, { waitUntil: "domcontentloaded" })
147+
expect(page).not.toBeNull()
148+
149+
for (let i = 0; i < 50; i++) {
150+
await expect(page.locator("body")).toHaveAttribute(
151+
"data-state-iframe-value",
152+
"true",
153+
)
154+
155+
// Find all internal links on the page (href starting with the same origin)
156+
const links = page.locator(`a[href^="${assistantUrl}"]`)
157+
158+
// Pick a random internal link to click
159+
const count = await links.count()
160+
const randomLink = links.nth(Math.floor(Math.random() * count))
161+
if (await randomLink.isVisible()) {
162+
await randomLink.click()
163+
}
164+
}
165+
})

previews/template_preview.py

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
# Ignore line length recommandations from flake8
22
# flake8: noqa: E501
3+
from django.conf import settings
4+
from django.template import Context, Template
35
from django.template.loader import render_to_string
46
from django_lookbook.preview import LookbookPreview
57

@@ -92,3 +94,69 @@ def suggestions(self, **kwargs):
9294
def share_and_embed(self, **kwargs):
9395
context = {"heading": "Faites découvrir ce site"}
9496
return render_to_string("snippets/share_and_embed.html", context)
97+
98+
99+
class IframePreview(LookbookPreview):
100+
def carte(self, **kwargs):
101+
template = Template(
102+
f"""
103+
<script src="{settings.ASSISTANT["BASE_URL"]}/static/carte.js"></script>
104+
""",
105+
)
106+
return template.render(Context({}))
107+
108+
def carte_sur_mesure(self, **kwargs):
109+
template = Template(
110+
f"""
111+
<script src="{settings.ASSISTANT["BASE_URL"]}/static/carte.js" data-slug="cyclevia"></script>
112+
""",
113+
)
114+
115+
return template.render(Context({}))
116+
117+
def carte_preconfiguree(self, **kwargs):
118+
template = Template(
119+
f"<script src='{settings.ASSISTANT['BASE_URL']}/static/carte.js'"
120+
"""data-action_displayed="preter|emprunter|louer|mettreenlocation|reparer|donner|echanger|acheter|revendre"
121+
data-max-width="800px"
122+
data-height="720px"
123+
data-bounding_box="{&quot;southWest&quot;: {&quot;lat&quot;: 47.570401, &quot;lng&quot;: 1.597977}, &quot;northEast&quot;: {&quot;lat&quot;: 48.313697, &quot;lng&quot;: 3.059159}}"
124+
></script>
125+
"""
126+
)
127+
128+
return template.render(Context({}))
129+
130+
def formulaire(self, **kwargs):
131+
template = Template(
132+
f"<script src='{settings.LVAO['BASE_URL']}/static/iframe.js'"
133+
"""
134+
data-max_width="100%"
135+
data-height="720px"
136+
data-direction="jai"
137+
data-first_dir="jai"
138+
data-action_list="reparer|echanger|mettreenlocation|revendre"
139+
data-iframe_attributes='{"loading":"lazy", "id" : "resize" }'>
140+
</script>
141+
"""
142+
)
143+
144+
return template.render(Context({}))
145+
146+
def assistant(self, **kwargs):
147+
template = Template(
148+
f"""
149+
<script src="{settings.ASSISTANT["BASE_URL"]}/iframe.js" data-testid='assistant'></script>
150+
"""
151+
)
152+
153+
return template.render(Context({}))
154+
155+
def assistant_without_referrer(self, **kwargs):
156+
template = Template(
157+
f"""
158+
<script src="{settings.ASSISTANT["BASE_URL"]}/iframe.js" data-debug-referrer data-testid='assistant'></script>
159+
"""
160+
)
161+
162+
return template.render(Context({}))

qfdmd/middleware.py

Lines changed: 33 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,22 @@ def __init__(self, get_response):
33
self.get_response = get_response
44

55
def __call__(self, request):
6+
# Prepare request
7+
self._prepare_request_if_iframe(request)
68
response = self.get_response(request)
79

10+
# Prepare response
811
self._set_logged_in_cookie(request, response)
9-
self._handle_iframe_cookie(request, response)
12+
self._persist_iframe_in_headers(request, response)
1013
self._cleanup_vary_header(response)
1114

1215
return response
1316

1417
def _set_logged_in_cookie(self, request, response):
15-
"""Set or update the 'logged-in' header based on authentication."""
18+
"""Set or update the 'logged-in' header based on authentication.
19+
It is use to bypass cache by nginx.
20+
21+
If present, the logged_in cookie bypasses the cache."""
1622
cookie_name = "logged_in"
1723

1824
# In some cases, gunicorn can be reached directly without going through
@@ -26,15 +32,32 @@ def _set_logged_in_cookie(self, request, response):
2632
elif request.COOKIES.get(cookie_name):
2733
response.delete_cookie(cookie_name)
2834

29-
def _handle_iframe_cookie(self, request, response):
30-
"""Manage iframe-related headers and cookies."""
31-
iframe_in_request = "iframe" in request.GET
32-
iframe_cookie = response.cookies.get("iframe")
35+
def _prepare_request_if_iframe(self, request):
36+
"""Detect if the request comes from an iframe mode.
37+
The iframe mode is usually set on the initial request, and must be passed
38+
during the navigation.
39+
To be RGPD-compliant, and to satisfy some of our users constraints, we
40+
cannot use Django session's cookie.
41+
We rely on a mix between querystring and referrer.
3342
34-
if iframe_in_request:
35-
response.set_cookie("iframe", "1")
36-
response.headers["iframe"] = "1"
37-
elif iframe_cookie and iframe_cookie.value == "1":
43+
We also have a client-side fallback, based on sessionStorage : on initial
44+
request, we set the iframe value in sessionStorage.
45+
"""
46+
is_in_iframe_mode = False
47+
if request.headers.get("Sec-Fetch-Dest") == "iframe":
48+
is_in_iframe_mode = True
49+
50+
if "iframe" in request.GET:
51+
is_in_iframe_mode = True
52+
53+
request.iframe = is_in_iframe_mode
54+
55+
def _persist_iframe_in_headers(self, request, response):
56+
"""Persist iframe state in headers.
57+
This is useful as headers are used as a cache key with nginx.
58+
iFrame version of pages are cached differently."""
59+
60+
if request.iframe:
3861
response.headers["iframe"] = "1"
3962
else:
4063
# Ensure the iframe header is not lingering

static/to_compile/controllers/assistant/state.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export default class extends Controller<HTMLElement> {
1919
declare iframeValue: boolean
2020

2121
connect() {
22-
document.addEventListener("turbo:frame-load", this.#initRecurringEvents.bind(this))
22+
document.addEventListener("turbo:load", this.#initRecurringEvents.bind(this))
2323
}
2424

2525
#initRecurringEvents(event) {
@@ -31,9 +31,27 @@ export default class extends Controller<HTMLElement> {
3131
this.configureIframeSpecificUI()
3232
}
3333

34+
#redirectIfIframeMisconfigured() {
35+
/**
36+
In some situation, the sessionStorage read a true value for iframe whereas
37+
the url does not contain the iframe param.
38+
The cause is yet unclear, but as fallback we detect this situation and redirect
39+
the user dynamically to the correct location, with iframe in the querystring.
40+
*/
41+
const url = new URL(window.location.href)
42+
const params = url.searchParams
43+
44+
if (!params.has("iframe")) {
45+
params.set("iframe", "1")
46+
url.search = params.toString()
47+
window.location.href = url.toString()
48+
}
49+
}
50+
3451
configureIframeSpecificUI() {
3552
if (sessionStorage.getItem("iframe") === "true") {
3653
this.iframeValue = true
54+
this.#redirectIfIframeMisconfigured()
3755
}
3856

3957
if (this.iframeValue) {
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
<div class="qf-bg-grey-1000-50-hover qf-py-9w">
2-
{% if assistant.is_iframe %}
2+
{% if request.iframe %}
33
{% include "./_embedded.html" %}
44
{% else %}
55
{% include "./_standalone.html" %}
66
{% endif %}
77
</div>
88

9-
{% if not assistant.is_iframe %}
9+
{% if not iframe %}
1010
{% include "./_dsfr_footer.html" %}
1111
{% endif %}

templates/components/header/base.iframe.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{% if not assistant.is_home %}
2-
<header role="banner" class="fr-container qf-filter-none fr-header max-md:qf-hidden">
2+
<header role="banner" class="fr-container qf-filter-none fr-header max-md:qf-hidden" data-testid="header-iframe">
33
<div class="fr-header__body">
44
<div class="fr-container">
55
<div class="fr-header__body-row">

0 commit comments

Comments
 (0)