- New apps/guides app with GuidePage model and audience mapping by wiki path - sync_guides management command: one-way read-only sync from wikijs DB - Read-only wikijs_ro DB role + WIKI_DB_* env config (secrets, gitignored) - Markdown rendering with bleach sanitisation; rewrites internal guide links to the portal help root and wiki image paths to WIKI_PUBLIC_BASE - /portal/help/ (buyer) and /ops/help/ (ops) index + detail views with role-based audience filtering (404 on cross-role guides) - Help nav item added to both portal base templates - Tests: audience filtering, markdown sanitisation, link rewriting, sync command
216 lines
8.4 KiB
Python
216 lines
8.4 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
|
|
|
|
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_sees_all(self):
|
|
self.client.force_login(self.admin_user)
|
|
for url in ("/portal/help/", "/ops/help/"):
|
|
resp = self.client.get(url)
|
|
self.assertEqual(resp.status_code, 200, msg=url)
|
|
self.assertContains(resp, self.buyer.title, msg_prefix=url)
|
|
self.assertContains(resp, self.ops.title, msg_prefix=url)
|
|
self.assertContains(resp, self.admin.title, msg_prefix=url)
|
|
|
|
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_external_and_anchor_links_left_alone(self):
|
|
html = render_markdown(
|
|
"[Plan](/en/home/development/plan)\n\n[Top](#buyer-portal-guides)\n\n[Email](mailto:a@b.c)",
|
|
"user-guides/index",
|
|
"/portal/help/",
|
|
)
|
|
self.assertIn('href="/en/home/development/plan"', html)
|
|
self.assertIn('href="#buyer-portal-guides"', html)
|
|
self.assertIn('href="mailto:a@b.c"', 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")
|