ixg_platform/apps/guides/tests.py
ixgadmin ee4f7b5232 Help centre: portal-scoped guide audiences, and links that resolve
Guide visibility is now scoped by PORTAL first and role second:

- the buyer portal lists shared and buyer guides, even for an admin, because it
  is the buyer portal; admin guides never surface there
- the ops portal lists shared and ops guides, plus admin guides for an
  admin/superuser
- the detail view names its context object, so guide pages get a real browser
  title instead of a blank one

Link rewriting, so one page can serve both the wiki and the in-app help centre:

- absolute /en/user-guides/... links become the in-app help path
- other absolute /en/... links point at the wiki itself; the app hosts serve no
  /en/ routes, so leaving them untouched was a 404 for whoever clicked
- relative links keep their existing behaviour

Tests cover the new rewriting and the portal scoping rules. Also adds
docs/USER_GUIDES_VIEWER_SPEC.md, the spec this viewer was built to.
2026-09-16 16:39:07 +00:00

327 lines
13 KiB
Python

from unittest import mock
from django.contrib.auth import get_user_model
from django.core.management import call_command
from django.test import TestCase, override_settings
from django.utils import timezone
from .markdown import render_markdown
from .models import GuidePage, allowed_audiences
User = get_user_model()
def make_user(email, role):
return User.objects.create_user(
email=email, password="pass1234", role=role,
full_name=email.split("@")[0], is_staff=True,
)
def make_page(path, content="# Heading\n\nBody text", title=None):
return GuidePage.objects.create(
path=path,
title=title or path.rsplit("/", 1)[-1],
audience=GuidePage.map_audience(path),
content=content,
updated_at=timezone.now(),
)
class GuideAudienceFilterTests(TestCase):
def setUp(self):
self.shared = make_page("user-guides/index", "# User Guides\n\nWelcome.", title="Landing Page")
self.buyer = make_page("user-guides/buyer/getting-started", title="Getting Started Guide")
self.buyer2 = make_page("user-guides/buyer/dashboard", title="Dashboard Guide")
self.ops = make_page("user-guides/ops/batches", title="Ops Batches Guide")
self.admin = make_page("user-guides/admin/overview", title="Admin Overview Guide")
self.buyer_user = make_user("buyer@ixg.local", "buyer")
self.ops_user = make_user("ops@ixg.local", "ops")
self.admin_user = make_user("admin@ixg.local", "admin")
def test_buyer_index_shows_shared_and_buyer_only(self):
self.client.force_login(self.buyer_user)
resp = self.client.get("/portal/help/")
self.assertEqual(resp.status_code, 200)
content = resp.content.decode()
self.assertContains(resp, self.shared.title)
self.assertContains(resp, self.buyer.title)
self.assertContains(resp, self.buyer2.title)
self.assertNotContains(resp, self.ops.title)
self.assertNotContains(resp, self.admin.title)
def test_ops_index_shows_shared_and_ops_only(self):
self.client.force_login(self.ops_user)
resp = self.client.get("/ops/help/")
self.assertEqual(resp.status_code, 200)
content = resp.content.decode()
self.assertContains(resp, self.shared.title)
self.assertContains(resp, self.ops.title)
self.assertNotContains(resp, self.buyer.title)
self.assertNotContains(resp, self.admin.title)
def test_admin_index_on_portal_lists_buyer_guides_only(self):
"""Portal scoping wins over role: the buyer portal shows buyer guides."""
self.client.force_login(self.admin_user)
resp = self.client.get("/portal/help/")
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, self.shared.title)
self.assertContains(resp, self.buyer.title)
self.assertContains(resp, self.buyer2.title)
self.assertNotContains(resp, self.ops.title)
self.assertNotContains(resp, self.admin.title)
def test_admin_index_on_ops_lists_ops_and_admin_guides(self):
self.client.force_login(self.admin_user)
resp = self.client.get("/ops/help/")
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, self.shared.title)
self.assertContains(resp, self.ops.title)
self.assertContains(resp, self.admin.title)
self.assertNotContains(resp, self.buyer.title)
def test_superuser_follows_the_same_portal_scoping(self):
superuser = make_user("root@ixg.local", "admin")
superuser.is_superuser = True
superuser.save(update_fields=["is_superuser"])
self.client.force_login(superuser)
resp = self.client.get("/portal/help/")
self.assertNotContains(resp, self.ops.title)
resp = self.client.get("/ops/help/")
self.assertContains(resp, self.admin.title)
def test_ops_only_user_is_refused_at_the_portal_gate(self):
"""Only buyer/admin roles reach /portal/ at all (BuyerRequiredMixin).
So portal scoping here matters for exactly two kinds of viewer: real
buyers, and admins who can reach both portals.
"""
self.client.force_login(self.ops_user)
self.assertEqual(self.client.get("/portal/help/").status_code, 403)
def test_portal_audiences_helper(self):
"""The scoping rule itself, independent of view wiring."""
A = GuidePage.Audience
self.assertEqual(
allowed_audiences("portal", role="buyer"),
{A.SHARED, A.BUYER},
)
self.assertEqual(
allowed_audiences("portal", role="admin"),
{A.SHARED, A.BUYER},
msg="admin does not gain ops/admin guides inside the buyer portal",
)
self.assertEqual(
allowed_audiences("ops", role="ops"),
{A.SHARED, A.OPS},
)
self.assertEqual(
allowed_audiences("ops", role="admin"),
{A.SHARED, A.OPS, A.ADMIN},
)
self.assertEqual(
allowed_audiences("ops", role="buyer", is_superuser=True),
{A.SHARED, A.OPS, A.ADMIN},
msg="superuser is treated like the admin role",
)
def test_admin_cannot_reach_ops_guide_through_portal(self):
self.client.force_login(self.admin_user)
resp = self.client.get("/portal/help/ops/batches/")
self.assertEqual(resp.status_code, 404)
resp = self.client.get("/portal/help/admin/overview/")
self.assertEqual(resp.status_code, 404)
# ...but the same guides are reachable through the ops help centre
self.assertEqual(self.client.get("/ops/help/ops/batches/").status_code, 200)
self.assertEqual(self.client.get("/ops/help/admin/overview/").status_code, 200)
def test_buyer_cannot_access_ops_or_admin_guide(self):
self.client.force_login(self.buyer_user)
resp = self.client.get("/portal/help/ops/batches/")
self.assertEqual(resp.status_code, 404)
resp = self.client.get("/portal/help/admin/overview/")
self.assertEqual(resp.status_code, 404)
def test_ops_cannot_access_buyer_guide(self):
self.client.force_login(self.ops_user)
resp = self.client.get("/ops/help/buyer/getting-started/")
self.assertEqual(resp.status_code, 404)
def test_buyer_can_access_buyer_guide(self):
self.client.force_login(self.buyer_user)
resp = self.client.get("/portal/help/buyer/getting-started/")
self.assertEqual(resp.status_code, 200)
self.assertContains(resp, "<h1")
def test_admin_can_access_admin_guide_via_ops(self):
self.client.force_login(self.admin_user)
resp = self.client.get("/ops/help/admin/overview/")
self.assertEqual(resp.status_code, 200)
def test_unknown_guide_is_404(self):
self.client.force_login(self.buyer_user)
resp = self.client.get("/portal/help/nope/missing/")
self.assertEqual(resp.status_code, 404)
class GuideMarkdownTests(TestCase):
def test_script_tags_are_stripped(self):
html = render_markdown(
"# Title\n\nHello <script>alert('x')</script> world",
"user-guides/buyer/test",
"/portal/help/",
)
self.assertNotIn("<script", html)
def test_tables_and_heading_render(self):
html = render_markdown(
"| A | B |\n|---|---|\n| 1 | 2 |",
"user-guides/buyer/test",
"/portal/help/",
)
self.assertIn("<table", html)
self.assertIn("<th", html)
@override_settings(WIKI_PUBLIC_BASE="http://wiki.test")
def test_same_folder_link_rewritten_to_help_root(self):
html = render_markdown(
"[Compliance guide](compliance)",
"user-guides/buyer/getting-started",
"/portal/help/",
)
self.assertIn('href="/portal/help/buyer/compliance/"', html)
@override_settings(WIKI_PUBLIC_BASE="http://wiki.test")
def test_index_relative_link_rewritten(self):
html = render_markdown(
"[Getting started](buyer/getting-started)",
"user-guides/index",
"/ops/help/",
)
self.assertIn('href="/ops/help/buyer/getting-started/"', html)
@override_settings(WIKI_PUBLIC_BASE="http://wiki.test")
def test_wiki_image_path_rewritten_to_public_base(self):
html = render_markdown(
"![Dashboard](/buyer-guide/buyer-dashboard.png)",
"user-guides/buyer/test",
"/portal/help/",
)
self.assertIn('src="http://wiki.test/buyer-guide/buyer-dashboard.png"', html)
def test_anchor_external_and_mailto_links_left_alone(self):
html = render_markdown(
"[Top](#buyer-portal-guides)\n\n[Email](mailto:a@b.c)\n\n"
"[Elsewhere](https://example.com/x)",
"user-guides/index",
"/portal/help/",
)
self.assertIn('href="#buyer-portal-guides"', html)
self.assertIn('href="mailto:a@b.c"', html)
self.assertIn('href="https://example.com/x"', html)
@override_settings(WIKI_PUBLIC_BASE="http://wiki.test")
def test_absolute_wiki_guide_link_rewritten_to_help_path(self):
"""`/en/user-guides/...` is mirrored in-app, so keep the reader in-app.
This is the link form the wiki itself needs (Wiki.js resolves a relative
link against the page's full path, so `walkthrough/signing-in` on the
page `user-guides/ops/walkthrough` 404s). Rewriting it here is what lets
one page serve both surfaces.
"""
html = render_markdown(
"[Step 3](/en/user-guides/ops/walkthrough/intakes)",
"user-guides/ops/walkthrough",
"/ops/help/",
)
self.assertIn('href="/ops/help/ops/walkthrough/intakes/"', html)
@override_settings(WIKI_PUBLIC_BASE="http://wiki.test")
def test_absolute_wiki_guide_link_to_an_index_page(self):
html = render_markdown(
"[Back to the index](/en/user-guides/index)",
"user-guides/buyer/getting-started",
"/portal/help/",
)
self.assertIn('href="/portal/help/index/"', html)
@override_settings(WIKI_PUBLIC_BASE="http://wiki.test")
def test_absolute_wiki_page_link_rewritten_to_the_wiki(self):
"""A wiki page with no in-app copy must point at the wiki.
The app hosts serve no /en/ routes, so leaving this href alone is a 404.
"""
html = render_markdown(
"[Development Plan](/en/home/development/plan)",
"user-guides/index",
"/ops/help/",
)
self.assertIn('href="http://wiki.test/en/home/development/plan"', html)
def test_absolute_app_paths_left_alone(self):
html = render_markdown(
"[Ops portal](/ops/batches/)", "user-guides/index", "/ops/help/"
)
self.assertIn('href="/ops/batches/"', html)
class FakeCursor:
def __init__(self, rows):
self.rows = rows
def __enter__(self):
return self
def __exit__(self, *exc):
return False
def execute(self, sql, params=None):
pass
def fetchall(self):
return self.rows
class SyncGuidesCommandTests(TestCase):
def test_sync_maps_audience_and_upserts(self):
rows = [
("user-guides/index", "User Guides", "# landing", "2026-08-07T13:14:15.809Z"),
("user-guides/buyer/getting-started", "Getting Started", "# gs", "2026-08-07T13:25:30.479Z"),
("user-guides/ops/batches", "Batches", "# b", "2026-08-07T13:51:48.332Z"),
("user-guides/admin/overview", "Admin Overview", "# a", "2026-08-07T13:52:15.763Z"),
]
with mock.patch(
"apps.guides.management.commands.sync_guides.connections"
) as mock_connections:
mock_connections["wikijs"].cursor.return_value = FakeCursor(rows)
call_command("sync_guides")
self.assertEqual(GuidePage.objects.count(), 4)
self.assertEqual(
GuidePage.objects.get(path="user-guides/index").audience, GuidePage.Audience.SHARED
)
self.assertEqual(
GuidePage.objects.get(path="user-guides/buyer/getting-started").audience,
GuidePage.Audience.BUYER,
)
self.assertEqual(
GuidePage.objects.get(path="user-guides/ops/batches").audience, GuidePage.Audience.OPS
)
self.assertEqual(
GuidePage.objects.get(path="user-guides/admin/overview").audience, GuidePage.Audience.ADMIN
)
def test_sync_updates_changed_and_removes_stale(self):
make_page("user-guides/buyer/old", content="old content")
rows = [
("user-guides/buyer/getting-started", "Getting Started", "# fresh", "2026-08-07T13:25:30.479Z"),
]
with mock.patch(
"apps.guides.management.commands.sync_guides.connections"
) as mock_connections:
mock_connections["wikijs"].cursor.return_value = FakeCursor(rows)
call_command("sync_guides")
self.assertFalse(GuidePage.objects.filter(path="user-guides/buyer/old").exists())
page = GuidePage.objects.get(path="user-guides/buyer/getting-started")
self.assertEqual(page.content, "# fresh")