پیشنمایش خصوصی: سایت آنلاین اما غیرعمومی تا زمان انتشار
- گیت ورود Traefik (Basic Auth) روی همه مسیرها: سایت، API، تصاویر و چت — بدون رمز فقط ۴۰۱ - لایه دوم noindex: هدر X-Robots-Tag روی همه پاسخها + robots.txt مسدودکننده + نقشه سایت خالی - اسکریپت scripts/preview-mode.sh on|off|status — ساخت خودکار نام کاربری/رمز و هماهنگکردن .env - دو دامنه (DOMAIN + DOMAIN_ALIAS) با گواهی TLS و ریدایرکت ۳۰۱ دامنه دوم به اصلی در زمان انتشار - اسکیمای FAQPage در پوسته سئوی سمت سرور (فارسی و انگلیسی) برای خزندههای بدون جاوااسکریپت - مستندسازی جریان پیشنمایش و چکلیست انتشار در README و .env.example
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
"""تستهای API — مسیرهای عمومی، احراز هویت، CRUD مدیریت، پیشنویس مقاله و سئو."""
|
||||
|
||||
import re
|
||||
import socket
|
||||
|
||||
DOC = {"type": "doc", "content": [{"type": "paragraph"}]}
|
||||
|
||||
@@ -361,3 +362,218 @@ def test_article_body_url_sanitization(auth_client):
|
||||
body = res.json()["body"]
|
||||
marks = body["content"][0]["content"][0].get("marks", [])
|
||||
assert all("href" not in m.get("attrs", {}) for m in marks)
|
||||
|
||||
|
||||
# ---------- نامگذاری تصاویر، درونریزی و پرسشوپاسخ مقاله ----------
|
||||
|
||||
|
||||
class _FakeS3:
|
||||
def __init__(self, *args, **kwargs):
|
||||
self.objects = {}
|
||||
|
||||
def put_object(self, **kwargs):
|
||||
self.objects[kwargs["Key"]] = kwargs["Body"]
|
||||
|
||||
|
||||
def _mock_storage(monkeypatch):
|
||||
"""MinIO در تست در دسترس نیست — کلاینت S3 و DNS عمومی جعل میشوند."""
|
||||
fake = _FakeS3()
|
||||
monkeypatch.setattr("app.api.admin.boto3.client", lambda *a, **k: fake)
|
||||
monkeypatch.setattr(
|
||||
"app.api.admin.socket.getaddrinfo",
|
||||
lambda host, port, *a, **k: [(socket.AF_INET, None, None, "", ("93.184.216.34", 0))],
|
||||
)
|
||||
return fake
|
||||
|
||||
|
||||
def _png_bytes() -> bytes:
|
||||
from PIL import Image
|
||||
import io
|
||||
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (8, 8), (200, 120, 60)).save(buf, "PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_upload_with_readable_name(auth_client, monkeypatch):
|
||||
_mock_storage(monkeypatch)
|
||||
res = auth_client.post(
|
||||
"/api/admin/upload",
|
||||
files={"file": ("raw name.png", _png_bytes(), "image/png")},
|
||||
data={"name": "برج الهیه tower!"},
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
key = res.json()["key"]
|
||||
# نام خوانا (فارسی/لاتین، بدون نویسه ناامن) + پسوند یکتا + پسوند تصویر
|
||||
assert "برج-الهیه-tower" in key
|
||||
assert key.endswith(".png")
|
||||
assert "!" not in key
|
||||
|
||||
|
||||
def test_upload_without_name_uses_filename(auth_client, monkeypatch):
|
||||
_mock_storage(monkeypatch)
|
||||
res = auth_client.post(
|
||||
"/api/admin/upload",
|
||||
files={"file": ("Elahieh Tower.jpg", _png_bytes(), "image/jpeg")},
|
||||
data={"name": ""},
|
||||
)
|
||||
assert res.status_code == 200
|
||||
assert "Elahieh-Tower" in res.json()["key"]
|
||||
|
||||
|
||||
def test_import_image_rejects_internal_host(auth_client):
|
||||
# SSRF — میزبان حلقهمحلی/خصوصی رد میشود پیش از هر درخواست شبکه
|
||||
res = auth_client.post("/api/admin/import-image", json={"url": "http://127.0.0.1:9000/x.png"})
|
||||
assert res.status_code == 400
|
||||
res = auth_client.post("/api/admin/import-image", json={"url": "http://192.168.1.5/x.png"})
|
||||
assert res.status_code == 400
|
||||
res = auth_client.post("/api/admin/import-image", json={"url": "file:///etc/passwd"})
|
||||
assert res.status_code == 400
|
||||
|
||||
|
||||
def test_import_image_rejects_bad_content_type(auth_client, monkeypatch):
|
||||
_mock_storage(monkeypatch)
|
||||
import io as _io
|
||||
|
||||
class FakeResponse:
|
||||
def __init__(self):
|
||||
self.headers = {"Content-Type": "text/html"}
|
||||
|
||||
def read(self, n):
|
||||
return b"<html></html>"
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
def fake_urlopen(request, timeout):
|
||||
return FakeResponse()
|
||||
|
||||
monkeypatch.setattr("app.api.admin.urllib.request.urlopen", fake_urlopen)
|
||||
res = auth_client.post("/api/admin/import-image", json={"url": "https://example.com/page"})
|
||||
assert res.status_code == 415
|
||||
|
||||
|
||||
def test_import_image_happy_path(auth_client, monkeypatch):
|
||||
_mock_storage(monkeypatch)
|
||||
class FakeResponse:
|
||||
def __init__(self):
|
||||
self.headers = {"Content-Type": "image/png"}
|
||||
|
||||
def read(self, n):
|
||||
return _png_bytes()
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *args):
|
||||
return False
|
||||
|
||||
monkeypatch.setattr("app.api.admin.urllib.request.urlopen", lambda request, timeout: FakeResponse())
|
||||
res = auth_client.post("/api/admin/import-image", json={"url": "https://wiki.example/Elahieh pic.png?token=1"})
|
||||
assert res.status_code == 200, res.text
|
||||
assert res.json()["url"].startswith("/media/")
|
||||
assert "Elahieh-pic" in res.json()["key"]
|
||||
|
||||
|
||||
def test_article_persists_image_meta_and_faqs(auth_client, client):
|
||||
created = auth_client.post(
|
||||
"/api/admin/articles",
|
||||
json={"slug": "meta-article", "title": "متادیتا", "body": DOC},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
article_id = created.json()["id"]
|
||||
|
||||
faqs = [{"question": "هزینه چقدر است؟", "answer": "بستگی به پروژه دارد."}]
|
||||
res = auth_client.patch(
|
||||
f"/api/admin/articles/{article_id}",
|
||||
json={
|
||||
"image_alt": "نمای برج الهیه",
|
||||
"image_title": "برج آرامش",
|
||||
"faqs": faqs,
|
||||
"faqs_en": [{"question": "What is the cost?", "answer": "Depends on the project."}],
|
||||
},
|
||||
)
|
||||
assert res.status_code == 200, res.text
|
||||
assert res.json()["image_alt"] == "نمای برج الهیه"
|
||||
assert res.json()["image_title"] == "برج آرامش"
|
||||
assert res.json()["faqs"] == faqs
|
||||
|
||||
# انتشار و سپس خروجی عمومی — همین مقادیر برمیگردد
|
||||
assert auth_client.patch(f"/api/admin/articles/{article_id}", json={"status": "published"}).status_code == 200
|
||||
public = client.get("/api/articles/meta-article").json()
|
||||
assert public["image_alt"] == "نمای برج الهیه"
|
||||
assert public["faqs"] == faqs
|
||||
|
||||
assert auth_client.delete(f"/api/admin/articles/{article_id}").status_code == 204
|
||||
|
||||
|
||||
def test_blog_shell_includes_faqpage_jsonld(auth_client, client):
|
||||
created = auth_client.post(
|
||||
"/api/admin/articles",
|
||||
json={
|
||||
"slug": "faq-article",
|
||||
"title": "مقاله پرسشوپاسخ",
|
||||
"body": DOC,
|
||||
"status": "published",
|
||||
"faqs": [{"question": "زمان ساخت؟", "answer": "۳۰ ماه."}],
|
||||
},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
|
||||
res = client.get("/blog/faq-article")
|
||||
assert res.status_code == 200
|
||||
assert "FAQPage" in res.text
|
||||
assert "زمان ساخت؟" in res.text
|
||||
|
||||
# نسخه انگلیسی بدون ترجمه → همان فارسی؛ با ترجمه → انگلیسی
|
||||
en = client.get("/en/blog/faq-article").text
|
||||
assert "FAQPage" in en
|
||||
article_id = created.json()["id"]
|
||||
auth_client.patch(
|
||||
f"/api/admin/articles/{article_id}",
|
||||
json={"faqs_en": [{"question": "How long?", "answer": "Thirty months."}]},
|
||||
)
|
||||
en = client.get("/en/blog/faq-article").text
|
||||
assert "How long?" in en
|
||||
assert "زمان ساخت؟" not in en
|
||||
|
||||
auth_client.delete(f"/api/admin/articles/{article_id}")
|
||||
|
||||
|
||||
def test_blog_shell_without_faqs_has_no_faqpage(client):
|
||||
res = client.get("/blog/published-post")
|
||||
assert res.status_code == 200
|
||||
assert "FAQPage" not in res.text
|
||||
|
||||
|
||||
# ---------- پیشنمایش خصوصی (برای انتشار عمومی خاموش میشود) ----------
|
||||
|
||||
|
||||
def test_preview_mode_blocks_robots_and_sitemap(client, monkeypatch):
|
||||
from app.config import settings
|
||||
|
||||
monkeypatch.setattr(settings, "preview_mode", True)
|
||||
try:
|
||||
robots = client.get("/robots.txt")
|
||||
assert robots.status_code == 200
|
||||
assert "Disallow: /" in robots.text
|
||||
assert "Sitemap:" not in robots.text
|
||||
|
||||
sitemap = client.get("/sitemap.xml")
|
||||
assert sitemap.status_code == 200
|
||||
assert "published-post" not in sitemap.text
|
||||
|
||||
# لایه دوم: هدر noindex روی همه پاسخها
|
||||
assert "noindex" in client.get("/api/health").headers.get("X-Robots-Tag", "")
|
||||
finally:
|
||||
monkeypatch.setattr(settings, "preview_mode", False)
|
||||
|
||||
|
||||
def test_public_mode_allows_indexing(client):
|
||||
robots = client.get("/robots.txt").text
|
||||
assert "Disallow: /admin" in robots
|
||||
assert "Sitemap:" in robots
|
||||
assert "noindex" not in client.get("/api/health").headers.get("X-Robots-Tag", "")
|
||||
|
||||
Reference in New Issue
Block a user