202 lines
No EOL
8.6 KiB
Python
202 lines
No EOL
8.6 KiB
Python
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
|
|
|
import hashlib
|
|
import hmac
|
|
import re
|
|
import time
|
|
from urllib.parse import unquote
|
|
|
|
from odoo.fields import Date
|
|
|
|
from odoo.tests import HttpCase, tagged
|
|
|
|
|
|
@tagged("post_install", "-at_install")
|
|
class TestMembershipSignupTour(HttpCase):
|
|
|
|
@classmethod
|
|
def setUpClass(cls):
|
|
super().setUpClass()
|
|
cls.website = cls.env.ref("website.default_website")
|
|
cls.website.auth_signup_uninvited = "b2b"
|
|
cls.website.membership_signup_required = True
|
|
today = Date.to_date(Date.today())
|
|
cls.membership_product = cls.env["product.template"].create({
|
|
"name": "Membership Test Product",
|
|
"membership": True,
|
|
"list_price": 60.0,
|
|
"is_published": True,
|
|
"sale_ok": True,
|
|
"website_id": cls.website.id,
|
|
"membership_date_from": today.replace(month=1, day=1),
|
|
"membership_date_to": today.replace(month=12, day=31),
|
|
})
|
|
cls.regular_product = cls.env["product.template"].create({
|
|
"name": "Regular Test Product",
|
|
"membership": False,
|
|
"list_price": 5.0,
|
|
"is_published": True,
|
|
"sale_ok": True,
|
|
"website_id": cls.website.id,
|
|
})
|
|
# Enable the custom/transfer payment provider if it is installed so the
|
|
# cart page renders fully (no checkout/payment is exercised by the
|
|
# tours, but it avoids warnings during cart rendering).
|
|
provider = cls.env.ref("payment.payment_provider_transfer", raise_if_not_found=False)
|
|
if provider and provider.state != "enabled":
|
|
provider.write({"state": "enabled", "is_published": True})
|
|
|
|
def test_01_membership_signup_flow(self):
|
|
self.start_tour(
|
|
self.env["website"].get_client_action_url("/shop"),
|
|
"website_membership_signup_required_tour",
|
|
login=None,
|
|
)
|
|
# The tour submitted the signup form, so a new portal user must exist
|
|
# and the cart of that user must contain the membership product.
|
|
users = self.env["res.users"].search([
|
|
("login", "like", "membership.tour.%@test.example.com"),
|
|
])
|
|
self.assertTrue(users, "Tour should have created a user via /web/signup")
|
|
first = users.sorted("create_date")[-1]
|
|
self.assertTrue(first.partner_id.firstname)
|
|
self.assertTrue(first.partner_id.lastname)
|
|
carts = self.env["sale.order"].search([
|
|
("partner_id", "=", first.partner_id.id),
|
|
("state", "=", "draft"),
|
|
])
|
|
self.assertTrue(carts)
|
|
order_lines = carts.mapped("order_line").filtered(
|
|
lambda l: l.product_id.product_tmpl_id == self.membership_product
|
|
)
|
|
self.assertTrue(order_lines, "Cart must contain the membership product")
|
|
|
|
def test_02_non_membership_no_intercept(self):
|
|
self.start_tour(
|
|
self.env["website"].get_client_action_url("/shop"),
|
|
"website_membership_signup_required_non_intercept_tour",
|
|
login=None,
|
|
)
|
|
|
|
# -- HTTP-level tests (no Chrome) --------------------------------------
|
|
|
|
_CSRF_RE = re.compile(r'name="csrf_token"\s+value="([^"]+)"')
|
|
|
|
def _compute_csrf_token(self):
|
|
"""Manually compute a valid csrf token bound to the opener's current
|
|
session_id cookie. Mirrors `request.csrf_token()` so the test client
|
|
does not need to scrape the rendered HTML (which some addons strip for
|
|
specific authenticated users).
|
|
"""
|
|
sid = self.opener.cookies.get("session_id")
|
|
if not sid:
|
|
return None
|
|
secret = self.env["ir.config_parameter"].sudo().get_param("database.secret")
|
|
max_ts = int(time.time() + 31536000) # 1y, matches Odoo's default.
|
|
msg = f"{sid}{max_ts}".encode("utf-8")
|
|
hm = hmac.new(secret.encode("ascii"), msg, hashlib.sha1).hexdigest()
|
|
return f"{hm}o{max_ts}"
|
|
|
|
def _cart_update(self, product, add_qty="1"):
|
|
"""POST to /shop/cart/update with a valid csrf token. The csrf token
|
|
is scraped from the rendered product page, with a fallback computed
|
|
directly from the opener's session_id cookie (covers sessions whose
|
|
rendered page strips the form for the logged user).
|
|
"""
|
|
product_url = "/shop/%s" % self.env["ir.http"]._slug(product)
|
|
page = self.url_open(product_url, allow_redirects=True)
|
|
self.assertEqual(
|
|
page.status_code, 200,
|
|
"Product page must render (got %s for %s)" % (page.status_code, product_url),
|
|
)
|
|
match = self._CSRF_RE.search(page.text)
|
|
csrf_token = match.group(1) if match else self._compute_csrf_token()
|
|
self.assertTrue(
|
|
csrf_token,
|
|
"Could not obtain a csrf_token (rendered page or session-based).",
|
|
)
|
|
return self.url_open(
|
|
"/shop/cart/update",
|
|
data={
|
|
"product_id": str(product.product_variant_id.id),
|
|
"add_qty": add_qty,
|
|
"csrf_token": csrf_token,
|
|
},
|
|
allow_redirects=False,
|
|
)
|
|
|
|
def _assert_redirect(self, resp):
|
|
self.assertTrue(
|
|
300 <= resp.status_code < 400,
|
|
"Expected a HTTP redirect, got %s. Location: %s"
|
|
% (resp.status_code, resp.headers.get("Location", "")),
|
|
)
|
|
|
|
def test_03_anon_membership_redirect(self):
|
|
"""RF-01 / RF-02: anonymous POST /shop/cart/update for a membership
|
|
product must redirect to /web/signup?membership_signup=1&redirect=...
|
|
without adding the product to the cart.
|
|
"""
|
|
resp = self._cart_update(self.membership_product)
|
|
self._assert_redirect(resp)
|
|
# The `Location` header is URL-encoded, so any path segment with
|
|
# non-safe chars (here the product slug) arrives percent-encoded.
|
|
# Assert on stable substrings that do not depend on the encoding of
|
|
# each part of the URL (RF-02).
|
|
location = resp.headers.get("Location", "")
|
|
self.assertIn("/web/signup", location)
|
|
self.assertIn("membership_signup=1", location)
|
|
self.assertIn("redirect=", location)
|
|
self.assertIn("membership-test-product", unquote(location))
|
|
|
|
def test_04_anon_regular_no_redirect(self):
|
|
"""RF-05: anonymous POST /shop/cart/update for a non-membership
|
|
product must follow the core flow (redirect to /shop/cart).
|
|
"""
|
|
resp = self._cart_update(self.regular_product)
|
|
self._assert_redirect(resp)
|
|
location = resp.headers.get("Location", "")
|
|
self.assertIn("/shop/cart", location)
|
|
self.assertNotIn("/web/signup", location)
|
|
|
|
def test_05_authenticated_membership_no_redirect(self):
|
|
"""RF-06: an authenticated (non-public) user adding a membership
|
|
product must NOT be redirected to /web/signup.
|
|
"""
|
|
self.authenticate("admin", "admin")
|
|
resp = self._cart_update(self.membership_product)
|
|
self._assert_redirect(resp)
|
|
location = resp.headers.get("Location", "")
|
|
self.assertIn("/shop/cart", location)
|
|
self.assertNotIn("/web/signup", location)
|
|
|
|
def test_06_signup_marker_page_renders(self):
|
|
"""RF-03: GET /web/signup?membership_signup=1&redirect=/shop/... must
|
|
return 200 (not 404) on a b2b website thanks to the conditional
|
|
override of `_get_signup_invitation_scope`.
|
|
"""
|
|
product_url = "/shop/%s" % self.env["ir.http"]._slug(self.membership_product)
|
|
params = {"membership_signup": "1", "redirect": product_url}
|
|
resp = self.url_open(
|
|
"/web/signup?" + "&".join(f"{k}={v}" for k, v in params.items()),
|
|
allow_redirects=False,
|
|
)
|
|
self.assertIn(resp.status_code, (200, 302))
|
|
if resp.status_code == 302:
|
|
# Could be redirected to login if a session already exists.
|
|
self.assertNotIn("web/login", resp.headers.get("Location", ""))
|
|
|
|
def test_07_disabled_intercept_anon_membership(self):
|
|
"""RF-07: with `membership_signup_required = False` on the website, an
|
|
anonymous POST /shop/cart/update for a membership product must follow
|
|
the core flow (no redirect to /web/signup).
|
|
"""
|
|
self.website.membership_signup_required = False
|
|
try:
|
|
resp = self._cart_update(self.membership_product)
|
|
self._assert_redirect(resp)
|
|
location = resp.headers.get("Location", "")
|
|
self.assertIn("/shop/cart", location)
|
|
self.assertNotIn("/web/signup", location)
|
|
finally:
|
|
self.website.membership_signup_required = True |