coulomb-social/tests/test_content_sync.py
tegwick 3f30bcd340 Add Forgejo edit/refresh sync path for space content (T05)
Keep git as source of truth: deep-link to Forgejo editor, manual refresh to
drop cache, and optional signed push webhook for automatic invalidation.
2026-08-12 03:29:26 +02:00

143 lines
4.2 KiB
Python

import hashlib
import hmac
import json
from unittest.mock import patch
import pytest
from django.urls import reverse
from coulomb_social.apps.members.models import Member
from coulomb_social.apps.spaces.content import (
clear_content_cache,
clear_content_cache_for_repo,
forgejo_edit_url,
)
from coulomb_social.apps.spaces.forgejo import FetchedFile
from coulomb_social.apps.spaces.models import Space
@pytest.mark.django_db
def test_forgejo_edit_url():
space = Space(
tenant_id="t",
slug="demo",
title="D",
forgejo_owner="coulomb",
forgejo_repo="coulomb-social",
default_branch="main",
content_root="docs/space-fixtures/demo/pages",
)
url = forgejo_edit_url(space, "index")
assert url.endswith(
"/coulomb/coulomb-social/_edit/main/docs/space-fixtures/demo/pages/index.md"
)
@pytest.mark.django_db
def test_refresh_clears_cache_and_refetches(client, settings):
clear_content_cache()
settings.DEBUG = True
settings.OIDC_ENABLED = False
client.post(
reverse("identity:dev_login"),
{
"subject": "writer",
"issuer": "https://local.dev/issuer",
"name": "Writer",
"tenant": "tenant:coulomb",
},
)
member = Member.objects.get(subject="writer")
Space.objects.create(
tenant_id="tenant:coulomb",
slug="demo",
title="Demo",
created_by=member,
forgejo_owner="coulomb",
forgejo_repo="coulomb-social",
content_root="docs/space-fixtures/demo/pages",
)
calls = {"n": 0}
def fake_fetch(**kwargs):
calls["n"] += 1
return FetchedFile(
path=kwargs["path"],
text=f"# Version {calls['n']}\n\nbody\n",
source="forgejo-raw",
)
with patch(
"coulomb_social.apps.spaces.content.fetch_raw_file", side_effect=fake_fetch
):
r1 = client.get(reverse("spaces:detail", kwargs={"slug": "demo"}))
assert r1.status_code == 200
assert b"Version 1" in r1.content
# cached — still v1
r2 = client.get(reverse("spaces:detail", kwargs={"slug": "demo"}))
assert b"Version 1" in r2.content
assert calls["n"] == 1
# refresh
r3 = client.post(reverse("spaces:refresh", kwargs={"slug": "demo"}))
assert r3.status_code == 302
r4 = client.get(reverse("spaces:detail", kwargs={"slug": "demo"}))
assert b"Version 2" in r4.content
assert calls["n"] == 2
@pytest.mark.django_db
def test_webhook_hmac_invalidates_repo_cache(client, settings):
clear_content_cache()
settings.FORGEJO_WEBHOOK_SECRET = "test-webhook-secret-at-least-16"
Space.objects.create(
tenant_id="tenant:coulomb",
slug="demo",
title="Demo",
forgejo_owner="coulomb",
forgejo_repo="coulomb-social",
content_root="pages",
)
# prime cache
from coulomb_social.apps.spaces import content as content_mod
key = (
"coulomb",
"coulomb-social",
"main",
"pages/index.md",
settings.FORGEJO_BASE_URL,
)
content_mod._FETCH_CACHE[key] = FetchedFile(
path="pages/index.md", text="# old", source="forgejo-raw"
)
body = json.dumps(
{"repository": {"full_name": "coulomb/coulomb-social", "name": "coulomb-social"}}
).encode()
sig = hmac.new(
settings.FORGEJO_WEBHOOK_SECRET.encode(), body, hashlib.sha256
).hexdigest()
r = client.post(
reverse("spaces:forgejo_webhook"),
data=body,
content_type="application/json",
headers={"X-Gitea-Signature": sig},
)
assert r.status_code == 200
data = r.json()
assert data["ok"] is True
assert data["cache_entries_cleared"] >= 1
assert data["spaces_matched"] == 1
assert key not in content_mod._FETCH_CACHE
@pytest.mark.django_db
def test_webhook_rejects_bad_secret(client, settings):
settings.FORGEJO_WEBHOOK_SECRET = "test-webhook-secret-at-least-16"
r = client.post(
reverse("spaces:forgejo_webhook"),
data=b"{}",
content_type="application/json",
headers={"X-Coulomb-Webhook-Secret": "wrong"},
)
assert r.status_code == 401