224 lines
9.1 KiB
Python
224 lines
9.1 KiB
Python
"""
|
|
Copyright 2024-2026 ReplyPier
|
|
|
|
Licensed under the Apache License, Version 2.0 (the "License");
|
|
you may not use this file except in compliance with the License.
|
|
You may obtain a copy of the License at
|
|
|
|
http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
Unless required by applicable law or agreed to in writing, software
|
|
distributed under the License is distributed on an "AS IS" BASIS,
|
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
See the License for the specific language governing permissions and
|
|
limitations under the License.
|
|
|
|
live_url mode selection, preserved-URL-path normalisation, and help-center
|
|
upload URL relativization.
|
|
"""
|
|
from types import SimpleNamespace
|
|
|
|
import pytest
|
|
|
|
from app.core.config import settings
|
|
from app.services.help_center_images import (
|
|
rewrite_baked_s3_image_urls,
|
|
store_article_image,
|
|
strip_upload_host,
|
|
)
|
|
from app.models.faq import FAQ, FAQ_URL_PATH_MAX_LENGTH
|
|
from app.services.help_center_settings import (
|
|
assign_faq_url_paths,
|
|
live_url,
|
|
normalize_url_path,
|
|
resolve_faq_url_path,
|
|
)
|
|
|
|
|
|
def _row(**kw):
|
|
base = {"domain_verified": False, "custom_domain": None, "slug": "acme"}
|
|
base.update(kw)
|
|
return SimpleNamespace(**base)
|
|
|
|
|
|
# ---------- live_url per mode ----------
|
|
|
|
def test_live_url_custom_domain_wins(monkeypatch):
|
|
"""A verified custom domain takes precedence in every mode."""
|
|
monkeypatch.setattr(settings, "HELP_CENTER_PUBLIC_MODE", "path")
|
|
row = _row(domain_verified=True, custom_domain="help.acme.com")
|
|
assert live_url(row) == "https://help.acme.com"
|
|
|
|
|
|
def test_live_url_subdomain_mode(monkeypatch):
|
|
monkeypatch.setattr(settings, "HELP_CENTER_PUBLIC_MODE", "subdomain")
|
|
monkeypatch.setattr(settings, "HELP_CENTER_BASE_DOMAIN", "help.replypier.site")
|
|
assert live_url(_row()) == "https://acme.help.replypier.site"
|
|
|
|
|
|
def test_live_url_path_mode_uses_backend_url(monkeypatch):
|
|
monkeypatch.setattr(settings, "HELP_CENTER_PUBLIC_MODE", "path")
|
|
monkeypatch.setattr(settings, "BACKEND_URL", "http://localhost:8000")
|
|
assert live_url(_row()) == "http://localhost:8000/help/acme"
|
|
|
|
|
|
# ---------- upload URL relativization ----------
|
|
|
|
def test_strip_upload_host_local_absolute():
|
|
md = ""
|
|
assert strip_upload_host(md) == ""
|
|
|
|
|
|
def test_strip_upload_host_leaves_s3_and_relative_untouched():
|
|
s3 = ""
|
|
rel = ""
|
|
assert strip_upload_host(s3) == s3
|
|
assert strip_upload_host(rel) == rel
|
|
|
|
|
|
def test_strip_upload_host_leaves_third_party_urls_untouched(monkeypatch):
|
|
"""A third-party URL that merely contains /api/v1/uploads/ (e.g. pasted into
|
|
an answer) must NOT be rewritten — only our own backend/loopback origin is."""
|
|
monkeypatch.setattr(settings, "BACKEND_URL", "http://localhost:8000")
|
|
foreign = "See https://legacy.vendor.com/api/v1/uploads/guide.pdf for details."
|
|
assert strip_upload_host(foreign) == foreign
|
|
|
|
|
|
def test_strip_upload_host_strips_configured_backend_origin(monkeypatch):
|
|
monkeypatch.setattr(settings, "BACKEND_URL", "https://self.example.com")
|
|
md = ""
|
|
assert strip_upload_host(md) == ""
|
|
|
|
|
|
@pytest.mark.asyncio
|
|
@pytest.mark.parametrize("stored", [
|
|
"/api/v1/uploads/help_center/x.png", # local storage
|
|
"https://bucket.s3.amazonaws.com/help_center/x.png", # S3
|
|
])
|
|
async def test_store_article_image_bakes_the_same_stable_path(monkeypatch, stored):
|
|
"""The baked URL is storage-agnostic and never expires.
|
|
|
|
It must not be a signed URL (they expire) nor a raw S3 URL (the bucket is
|
|
private, so those 403) — the delivery route resolves it per request.
|
|
"""
|
|
async def fake_store_upload(content, folder=None, file_name=None, content_type=None):
|
|
return stored
|
|
|
|
monkeypatch.setattr("app.services.help_center_images.store_upload", fake_store_upload)
|
|
url = await store_article_image(b"x", "image/png")
|
|
|
|
assert url.startswith("/api/v1/help-center/images/")
|
|
assert not url.startswith("http") # relative — resolves on whichever origin serves the page
|
|
assert "amazonaws" not in url and "Signature=" not in url
|
|
|
|
|
|
@pytest.mark.parametrize("text,expected", [
|
|
# virtual-hosted
|
|
("", ""),
|
|
# path-style, dotted bucket
|
|
("", ""),
|
|
# local paths and unrelated URLs are left alone
|
|
("", ""),
|
|
("", ""),
|
|
])
|
|
def test_rewrite_baked_s3_image_urls(text, expected):
|
|
name = "7b6fe1aa-1234-4c5d-9e0f-0123456789ab.png"
|
|
assert rewrite_baked_s3_image_urls(text.format(n=name)) == expected.format(n=name)
|
|
|
|
|
|
# ---------- preserved original URL paths ----------
|
|
|
|
@pytest.mark.parametrize("raw,expected", [
|
|
# A full source URL is reduced to its path.
|
|
("https://support.acme.com/hc/en-us/articles/360012-reset", "/hc/en-us/articles/360012-reset"),
|
|
("//support.acme.com/hc/articles/1", "/hc/articles/1"),
|
|
# Query strings and fragments are tracking/anchor noise, not identity.
|
|
("https://support.acme.com/hc/articles/1?utm_source=x#top", "/hc/articles/1"),
|
|
# Hand-typed forms: leading slash optional, trailing/duplicate slashes normalised.
|
|
("hc/articles/1", "/hc/articles/1"),
|
|
("/hc//articles/1/", "/hc/articles/1"),
|
|
(" /hc/articles/1 ", "/hc/articles/1"),
|
|
# Stored DECODED, because that's what the ASGI scope gives the router.
|
|
("/hc/en-us/articles/caf%C3%A9", "/hc/en-us/articles/café"),
|
|
])
|
|
def test_normalize_url_path_accepts(raw, expected):
|
|
assert normalize_url_path(raw) == expected
|
|
|
|
|
|
@pytest.mark.parametrize("raw", [
|
|
None, "", " ", "/", "//", "https://support.acme.com",
|
|
# Would shadow a real public route (first-segment match).
|
|
"/a/some-article", "/ask", "/api/v1/uploads/x", "/healthz", "/robots.txt",
|
|
"/sitemap.xml", "/help/acme/a/x", "/.well-known/acme-challenge/x",
|
|
"/uploads/x", "/static/x", "/ASK",
|
|
# Traversal / non-canonical.
|
|
"/hc/../etc/passwd", "/hc/./articles",
|
|
# Percent-encoded forms of all of the above: unquote runs BEFORE the segment
|
|
# checks, so encoding must not smuggle a traversal or a reserved prefix past
|
|
# them. Getting that order wrong is the classic hole in a path normaliser.
|
|
"/hc/%2e%2e/etc/passwd", "/%61sk", "/%41SK", "/hc/articles/1%00",
|
|
# Control characters (header/log injection).
|
|
"/hc/articles\n/x",
|
|
])
|
|
def test_normalize_url_path_rejects(raw):
|
|
assert normalize_url_path(raw) is None
|
|
|
|
|
|
def test_normalize_url_path_rejects_overlong():
|
|
assert normalize_url_path("/" + "x" * FAQ_URL_PATH_MAX_LENGTH) is None
|
|
|
|
|
|
def test_normalize_url_path_allows_prefix_lookalikes():
|
|
"""Reserving the FIRST SEGMENT must not reject paths that merely start with
|
|
the same letters."""
|
|
assert normalize_url_path("/apix/articles/1") == "/apix/articles/1"
|
|
assert normalize_url_path("/articles/asking") == "/articles/asking"
|
|
|
|
|
|
def _faq_row(org_id, question):
|
|
return FAQ(organization_id=org_id, question=question, answer="body")
|
|
|
|
|
|
def test_assign_faq_url_paths_applies_and_skips_collisions(db, test_organization):
|
|
"""First row wins a duplicated path; the batch-mate and an unusable source
|
|
fall back to /a/{slug} rather than failing the import."""
|
|
rows = [_faq_row(test_organization.id, f"Q{i}") for i in range(4)]
|
|
sources = [
|
|
"https://help.acme.com/hc/articles/1",
|
|
"https://help.acme.com/hc/articles/1", # duplicate within the batch
|
|
"https://help.acme.com/a/reserved", # unusable — reserved segment
|
|
"https://help.acme.com/hc/articles/2",
|
|
]
|
|
|
|
applied = assign_faq_url_paths(db, rows, sources)
|
|
|
|
assert applied == 2
|
|
assert [r.url_path for r in rows] == ["/hc/articles/1", None, None, "/hc/articles/2"]
|
|
|
|
|
|
def test_assign_faq_url_paths_skips_paths_taken_in_the_db(db, test_organization):
|
|
existing = _faq_row(test_organization.id, "Existing")
|
|
existing.slug = "existing"
|
|
existing.url_path = "/hc/articles/1"
|
|
db.add(existing)
|
|
db.commit()
|
|
|
|
rows = [_faq_row(test_organization.id, "New")]
|
|
assert assign_faq_url_paths(db, rows, ["https://help.acme.com/hc/articles/1"]) == 0
|
|
assert rows[0].url_path is None
|
|
|
|
|
|
def test_resolve_faq_url_path_returns_none_when_taken_but_allows_own_row(db, test_organization):
|
|
existing = _faq_row(test_organization.id, "Existing")
|
|
existing.slug = "existing"
|
|
existing.url_path = "/hc/articles/1"
|
|
db.add(existing)
|
|
db.commit()
|
|
|
|
assert resolve_faq_url_path(db, test_organization.id, "/hc/articles/1") is None
|
|
# Re-saving the same article keeps its own path instead of colliding.
|
|
assert resolve_faq_url_path(
|
|
db, test_organization.id, "https://help.acme.com/hc/articles/1", exclude_id=existing.id
|
|
) == "/hc/articles/1"
|
|
assert resolve_faq_url_path(db, test_organization.id, "/ask") is None
|