# 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