Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
67 changes: 67 additions & 0 deletions apps/onboarding/tests.py
Original file line number Diff line number Diff line change
Expand Up @@ -84,3 +84,70 @@ def test_oauth_callback_replays_verifier(self, client, workspace, connection_lin
mock_provider.exchange_code.assert_called_once()
_, kwargs = mock_provider.exchange_code.call_args
assert kwargs["code_verifier"] == verifier


@pytest.mark.django_db
class TestConnectionLinkPageTokens:
"""A Page must be driven by its own token, and a Page we had to skip must
not leave the client on a success page for an account never connected."""

def _callback(self, client, workspace, connection_link, pages):
nonce = "nonce-pages"
state = _sign_connection_link_state(workspace.id, "facebook", connection_link.token, nonce)
session = client.session
session[CONNECTION_LINK_OAUTH_SESSION_KEY] = {
"nonce": nonce,
"workspace_id": str(workspace.id),
"platform": "facebook",
"token": connection_link.token,
}
session.save()

provider = MagicMock()
provider.exchange_code.return_value = OAuthTokens(access_token="USER-TOKEN", refresh_token="", expires_in=None)
provider.get_profile.return_value = AccountProfile(platform_id="me-1", name="Owner")
provider.get_user_pages.return_value = pages

url = reverse("onboarding:oauth_callback", kwargs={"platform": "facebook"})
with (
patch("apps.onboarding.views._get_provider_for_platform", return_value=provider),
patch("apps.social_accounts.views.subscribe_account_webhooks_task"),
):
response = client.get(url, {"code": "auth-code", "state": state})
return response

def test_a_page_is_connected_with_its_own_token(self, client, workspace, connection_link):
from apps.social_accounts.models import SocialAccount

response = self._callback(
client,
workspace,
connection_link,
[{"id": "page-1", "name": "Page One", "access_token": "PAGE-TOKEN"}],
)

assert response.status_code == 302
account = SocialAccount.objects.get(account_platform_id="page-1")
assert account.oauth_access_token == "PAGE-TOKEN"
assert "connection_link_error" not in client.session

def test_a_page_without_its_own_token_is_reported_not_silently_dropped(self, client, workspace, connection_link):
from apps.social_accounts.models import SocialAccount

response = self._callback(
client,
workspace,
connection_link,
[
{"id": "page-1", "name": "Page One", "access_token": "PAGE-TOKEN"},
{"id": "page-2", "name": "Page Two"},
],
)

assert response.status_code == 302
assert SocialAccount.objects.filter(account_platform_id="page-1").exists()
# Never connected with the user token as a stand-in.
assert not SocialAccount.objects.filter(account_platform_id="page-2").exists()
error = client.session["connection_link_error"]
assert "Page Two" in error
assert "Page One" not in error
26 changes: 25 additions & 1 deletion apps/onboarding/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
_get_configured_platforms,
_normalize_mastodon_instance_url,
_resolve_mastodon_extra_creds,
resolve_page_account_token,
)

from .models import ConnectionLink, ConnectionLinkUsage, OnboardingChecklist
Expand Down Expand Up @@ -424,7 +425,23 @@ def connection_oauth_callback(request, platform):
if pages:
from providers.types import AccountProfile

skipped: list[str] = []

for page in pages:
access_token = resolve_page_account_token(page, platform, tokens.access_token)
if not access_token:
# Silently dropping these would leave the client on a
# success page for accounts that were never connected.
name = page.get("name") or page["id"]
skipped.append(name)
logger.warning(
"Connection link %s: %s provided no account token for %s; skipping.",
link.id,
platform,
name,
)
continue

page_profile = AccountProfile(
platform_id=page["id"],
name=page["name"],
Expand All @@ -436,7 +453,7 @@ def connection_oauth_callback(request, platform):
workspace_id=workspace_id,
platform=platform,
profile=page_profile,
access_token=page.get("access_token", tokens.access_token),
access_token=access_token,
refresh_token=tokens.refresh_token,
expires_in=tokens.expires_in,
# Instagram-via-Facebook receives its webhooks through
Expand All @@ -447,6 +464,13 @@ def connection_oauth_callback(request, platform):
connection_link=link,
social_account=account,
)

if skipped:
names = ", ".join(skipped)
request.session["connection_link_error"] = (
f"Could not connect {names}: the platform did not provide an account token. "
"Check that you granted access to those accounts, then try again."
)
return redirect("onboarding:connection_page", token=token)

# Standard single-account flow
Expand Down
4 changes: 2 additions & 2 deletions apps/social_accounts/management/commands/diagnose_facebook.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,13 +97,13 @@ def check(name, ok, detail):
self._check_local_state(account, check)

try:
from apps.social_accounts.views import _apply_analytics_scope_flag, _get_provider_for_platform
from apps.social_accounts.provider_factory import _get_provider_for_platform, apply_analytics_scope_flag

provider = _get_provider_for_platform("facebook", account.workspace.organization_id)
# Mirror what the OAuth flow does, or required_scopes reports
# read_insights as expected on a deployment where analytics is
# deliberately off — and a healthy token gets reported as broken.
_apply_analytics_scope_flag(provider, "facebook")
apply_analytics_scope_flag(provider, "facebook")
except Exception as exc:
check("provider", False, f"could not build the Facebook provider: {exc}")
return report
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
# Generated by Django 5.1.15 on 2026-09-10 12:43

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('social_accounts', '0017_seed_missing_analytics_platform_config'),
]

operations = [
migrations.AddField(
model_name='socialaccount',
name='missing_scopes',
field=models.JSONField(blank=True, default=list),
),
]
23 changes: 23 additions & 0 deletions apps/social_accounts/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,14 @@ class ConnectionStatus(models.TextChoices):
# a reconnect, and by the user pressing "Try again".
webhook_retry_count = models.PositiveSmallIntegerField(default=0)

# Scopes we asked for that the grant came back without. Meta drops
# unapproved or declined permissions silently rather than failing the
# grant, so without this the account looks healthy and only breaks later at
# publish or insights time with an opaque platform error. Empty means
# "everything we asked for was granted", or that the platform gives us no
# way to ask.
missing_scopes = models.JSONField(default=list, blank=True)

# Connection health
connection_status = models.CharField(
max_length=20,
Expand Down Expand Up @@ -247,6 +255,21 @@ def field_config(self) -> dict:
"""Return field configuration for this platform."""
return {**self.PLATFORM_FIELD_DEFAULTS, **self.PLATFORM_FIELD_CONFIG.get(self.platform, {})}

@property
def keeps_platform_grant_on_disconnect(self) -> bool:
"""True when disconnecting here cannot revoke the platform's grant.

The Facebook-Page flows share one grant across every Page and Instagram
account that person connected, so the only endpoint that would revoke
it takes all of them down at once — see
``FacebookProvider.revoke_token``. Instagram Login is excluded: its
token belongs to the one account, so disconnect does revoke it.
"""
return self.platform in {
PlatformCredential.Platform.FACEBOOK,
PlatformCredential.Platform.INSTAGRAM,
}

def supports_first_comment(self) -> bool:
"""Whether this account can have a first comment posted by the worker.

Expand Down
15 changes: 15 additions & 0 deletions apps/social_accounts/provider_factory.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,3 +19,18 @@ def _get_provider_for_platform(platform: str, org_id, **extra_credentials):
credentials = {**credentials, **extra_credentials}

return get_provider(platform, credentials)


def apply_analytics_scope_flag(provider, platform: str) -> None:
"""Set ``provider.include_analytics_scopes`` from AnalyticsPlatformConfig.

Providers add their analytics-only scopes (e.g. ``read_insights``) to the
OAuth scope list only when this flag is True, so a self-hoster whose Meta or
Google app has not been approved for them can still connect for publishing.

Anything reasoning about *what we asked for* must apply this first — the
unconditional ``required_scopes`` is not what OAuth requested.
"""
from apps.social_accounts.models import AnalyticsPlatformConfig

provider.include_analytics_scopes = platform in AnalyticsPlatformConfig.enabled_platforms()
2 changes: 1 addition & 1 deletion apps/social_accounts/tests/test_diagnose_facebook.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,7 +49,7 @@ def _provider(*, scopes, subscribed_fields, app_id="app-1"):

def _run(account):
out = StringIO()
with patch("apps.social_accounts.views._get_provider_for_platform") as factory:
with patch("apps.social_accounts.provider_factory._get_provider_for_platform") as factory:
factory.return_value = account["provider"]
# Non-zero exit is the documented signal that a check failed; the
# report is still what we assert on.
Expand Down
169 changes: 169 additions & 0 deletions apps/social_accounts/tests/test_webhook_subscription.py
Original file line number Diff line number Diff line change
Expand Up @@ -624,3 +624,172 @@ def test_reconnecting_refunds_the_automatic_retry_budget(workspace):
)

assert account.webhook_retry_count == 0


# --------------------------------------------------------------- token scoping


def test_a_page_must_use_its_own_token():
from apps.social_accounts.views import resolve_page_account_token

page = {"id": "page-1", "access_token": "PAGE-TOKEN"}
assert resolve_page_account_token(page, "facebook", "USER-TOKEN") == "PAGE-TOKEN"


def test_a_page_without_its_own_token_is_not_connectable():
"""Substituting the user token publishes under the wrong identity, and made
the account eligible for a revoke that severs its siblings."""
from apps.social_accounts.views import resolve_page_account_token

assert resolve_page_account_token({"id": "page-1"}, "facebook", "USER-TOKEN") == ""


def test_instagram_via_facebook_may_fall_back_to_the_user_token():
"""Its calls address the IG user, so the user token is the right credential."""
from apps.social_accounts.views import resolve_page_account_token

assert resolve_page_account_token({"id": "ig-1"}, "instagram", "USER-TOKEN") == "USER-TOKEN"


def test_both_connect_flows_share_one_resolver():
"""select_account and the connection-link flow diverging is what let a user
token reach a Page in the first place."""
import inspect

from apps.onboarding import views as onboarding_views
from apps.social_accounts import views as accounts_views

for module in (onboarding_views, accounts_views):
assert "resolve_page_account_token(" in inspect.getsource(module)


def test_disconnect_never_revokes_the_whole_facebook_user():
"""DELETE /me/permissions revokes the app for the entire person, so one
workspace disconnecting one Page would take down every other account."""
from unittest.mock import MagicMock as _Mock

from providers.facebook import FacebookProvider
from providers.instagram import InstagramProvider

for cls in (FacebookProvider, InstagramProvider):
provider = cls({"client_id": "id", "client_secret": "secret"})
provider._request = _Mock()

assert provider.revoke_token("any-token") is False
provider._request.assert_not_called()


def test_only_the_page_flows_warn_that_disconnect_keeps_the_grant(workspace):
"""Instagram Login's token belongs to the one account, so it really does
revoke on disconnect and must not carry the warning."""
for platform, expected in (
("facebook", True),
("instagram", True),
("instagram_login", False),
("bluesky", False),
):
account = SocialAccount(workspace=workspace, platform=platform, account_platform_id="x", account_name="x")
assert account.keeps_platform_grant_on_disconnect is expected, platform


# ------------------------------------------------------------- missing scopes


def test_a_partial_grant_is_recorded_on_the_account(workspace):
"""The whole Meta saga started with a scope silently absent from a grant."""
from apps.social_accounts.webhooks import record_missing_scopes

account = _account(workspace)
provider = MagicMock()
provider.required_scopes = ["pages_show_list", "pages_manage_posts", "read_insights"]
provider.get_granted_scopes.return_value = {"pages_show_list", "pages_manage_posts"}

with patch("apps.social_accounts.webhooks._get_provider_for_platform", return_value=provider):
assert record_missing_scopes(account) == ["read_insights"]

account.refresh_from_db()
assert account.missing_scopes == ["read_insights"]


def test_a_complete_grant_records_nothing(workspace):
from apps.social_accounts.webhooks import record_missing_scopes

account = _account(workspace, missing_scopes=["read_insights"])
provider = MagicMock()
provider.required_scopes = ["pages_show_list"]
provider.get_granted_scopes.return_value = {"pages_show_list", "extra_scope"}

with patch("apps.social_accounts.webhooks._get_provider_for_platform", return_value=provider):
assert record_missing_scopes(account) == []

account.refresh_from_db()
# A fresh, complete grant must clear a stale warning.
assert account.missing_scopes == []


def test_an_unanswerable_platform_leaves_the_field_alone(workspace):
"""None means unknown. Treating it as "nothing granted" would flag every
scope on every platform that cannot be asked."""
from apps.social_accounts.webhooks import record_missing_scopes

account = _account(workspace, missing_scopes=["previously_noted"])
provider = MagicMock()
provider.required_scopes = ["pages_show_list"]
provider.get_granted_scopes.return_value = None

with patch("apps.social_accounts.webhooks._get_provider_for_platform", return_value=provider):
assert record_missing_scopes(account) == []

account.refresh_from_db()
assert account.missing_scopes == ["previously_noted"]


def test_a_readback_failure_does_not_break_the_connect_flow(workspace):
from apps.social_accounts.webhooks import record_missing_scopes

account = _account(workspace)
provider = MagicMock()
provider.get_granted_scopes.side_effect = RuntimeError("boom")

with patch("apps.social_accounts.webhooks._get_provider_for_platform", return_value=provider):
assert record_missing_scopes(account) == []


def test_scopes_omitted_by_design_are_not_reported_missing(workspace):
"""Connect drops the analytics-only scopes when analytics is off for the
platform, so comparing against the unconditional list would demand a
reconnect for something we deliberately never asked for."""
from apps.social_accounts.models import AnalyticsPlatformConfig
from apps.social_accounts.webhooks import record_missing_scopes

AnalyticsPlatformConfig.objects.update_or_create(platform="facebook", defaults={"is_enabled": False})
account = _account(workspace, platform="facebook")

from providers.facebook import FacebookProvider

provider = FacebookProvider({"client_id": "id", "client_secret": "secret"})
provider.get_granted_scopes = MagicMock(return_value=set(provider.required_scopes) - {"read_insights"})

with patch("apps.social_accounts.webhooks._get_provider_for_platform", return_value=provider):
assert record_missing_scopes(account) == []

account.refresh_from_db()
assert account.missing_scopes == []


def test_a_scope_missing_while_analytics_is_on_is_still_reported(workspace):
from apps.social_accounts.models import AnalyticsPlatformConfig
from apps.social_accounts.webhooks import record_missing_scopes

AnalyticsPlatformConfig.objects.update_or_create(platform="facebook", defaults={"is_enabled": True})
account = _account(workspace, platform="facebook")

from providers.facebook import FacebookProvider

provider = FacebookProvider({"client_id": "id", "client_secret": "secret"})
provider.include_analytics_scopes = True
granted = set(provider.required_scopes) - {"read_insights"}
provider.get_granted_scopes = MagicMock(return_value=granted)

with patch("apps.social_accounts.webhooks._get_provider_for_platform", return_value=provider):
assert record_missing_scopes(account) == ["read_insights"]
Loading
Loading