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.
327 lines
13 KiB
Python
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(
|
|
"",
|
|
"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")
|