Spaces:
Running
Running
| """Opt-in, destructive LinkedIn integration verification. | |
| Normal CI always skips this module. Run it only with a dedicated LinkedIn | |
| member and, when organization coverage is required, a dedicated organization. | |
| Provider credentials are read from the process environment and never logged. | |
| """ | |
| from __future__ import annotations | |
| import asyncio | |
| import os | |
| from pathlib import Path | |
| from urllib.parse import parse_qs, urlparse | |
| import pytest | |
| pytestmark = pytest.mark.skipif( | |
| os.getenv("RUN_LINKEDIN_INTEGRATION_TESTS", "").lower() != "true", | |
| reason=( | |
| "LinkedIn live integration is NOT VERIFIED; set " | |
| "RUN_LINKEDIN_INTEGRATION_TESTS=true with dedicated credentials." | |
| ), | |
| ) | |
| def _required(name: str) -> str: | |
| value = os.getenv(name, "").strip() | |
| if not value: | |
| pytest.skip(f"LinkedIn live integration is NOT VERIFIED; missing {name}.") | |
| return value | |
| def _granted_scopes() -> list[str]: | |
| value = os.getenv("LINKEDIN_LIVE_TEST_GRANTED_SCOPES", "") | |
| return list(dict.fromkeys(value.replace(",", " ").split())) | |
| def _require_base_configuration() -> None: | |
| for name in ( | |
| "LINKEDIN_CLIENT_ID", | |
| "LINKEDIN_CLIENT_SECRET", | |
| "LINKEDIN_REDIRECT_URI", | |
| "LINKEDIN_LIVE_TEST_ACCESS_TOKEN", | |
| ): | |
| _required(name) | |
| def _require_publish_consent() -> None: | |
| _require_base_configuration() | |
| if os.getenv("LINKEDIN_LIVE_TEST_ALLOW_PUBLISH", "").lower() != "true": | |
| pytest.skip( | |
| "Set LINKEDIN_LIVE_TEST_ALLOW_PUBLISH=true to create test posts." | |
| ) | |
| if os.getenv("LINKEDIN_LIVE_TEST_DELETE", "").lower() != "true": | |
| pytest.skip( | |
| "Set LINKEDIN_LIVE_TEST_DELETE=true to require deletion of test posts." | |
| ) | |
| def _settings(): | |
| from app.core.config import Settings | |
| return Settings( | |
| _env_file=None, | |
| auth_enabled=False, | |
| linkedin_client_id=_required("LINKEDIN_CLIENT_ID"), | |
| linkedin_client_secret=_required("LINKEDIN_CLIENT_SECRET"), | |
| linkedin_redirect_uri=_required("LINKEDIN_REDIRECT_URI"), | |
| linkedin_publishing_enabled=True, | |
| whisper_model="tiny", | |
| ) | |
| def _token() -> dict[str, object]: | |
| return { | |
| "access_token": _required("LINKEDIN_LIVE_TEST_ACCESS_TOKEN"), | |
| "_mediarouter_granted_scopes": _granted_scopes(), | |
| } | |
| async def _identity(provider, token: dict[str, object]) -> tuple[str, str]: | |
| account_type = os.getenv( | |
| "LINKEDIN_LIVE_TEST_ACCOUNT_TYPE", "linkedin_member" | |
| ).strip() | |
| if account_type == "linkedin_member": | |
| member = await provider.get_account(token) | |
| return account_type, str(member["external_account_id"]) | |
| if account_type != "linkedin_organization": | |
| pytest.skip( | |
| "LINKEDIN_LIVE_TEST_ACCOUNT_TYPE must be linkedin_member or " | |
| "linkedin_organization." | |
| ) | |
| organization_id = _required("LINKEDIN_LIVE_TEST_ORGANIZATION_ID") | |
| discovered = await provider.discover_accounts( | |
| token, account_type="linkedin_organization" | |
| ) | |
| organizations = { | |
| str(account["external_account_id"]): account | |
| for account in discovered | |
| if account.get("account_type") == "linkedin_organization" | |
| } | |
| if organization_id not in organizations: | |
| pytest.fail( | |
| "The configured LinkedIn organization was not returned by official " | |
| "organization-access discovery." | |
| ) | |
| return account_type, organization_id | |
| async def _publish_text(provider, token: dict[str, object]) -> dict[str, object]: | |
| account_type, account_id = await _identity(provider, token) | |
| state: dict[str, object] = {} | |
| async def persist(value: dict[str, object]) -> None: | |
| state.clear() | |
| state.update(value) | |
| return await provider.publish( | |
| token, | |
| { | |
| "provider_account_id": account_id, | |
| "provider_account_type": account_type, | |
| "linkedin_post_metadata": { | |
| "post_type": "text", | |
| "commentary": ( | |
| "MediaRouter Phase 6C live integration verification" | |
| ), | |
| }, | |
| "upload": {"identity_type": "none"}, | |
| "provider_state": state, | |
| "persist_provider_state": persist, | |
| }, | |
| ) | |
| def test_linkedin_live_configuration_requires_explicit_opt_in() -> None: | |
| assert os.getenv("RUN_LINKEDIN_INTEGRATION_TESTS", "").lower() == "true" | |
| _require_base_configuration() | |
| async def test_linkedin_live_authorization_url_and_account_discovery() -> None: | |
| """Verify the official authorization contract and current identity token.""" | |
| _require_base_configuration() | |
| from app.social.providers.linkedin import LinkedInProvider | |
| provider = LinkedInProvider(_settings()) | |
| token = _token() | |
| account_type = os.getenv( | |
| "LINKEDIN_LIVE_TEST_ACCOUNT_TYPE", "linkedin_member" | |
| ).strip() | |
| additional_scopes = provider.account_type_scopes(account_type) | |
| try: | |
| authorization_url = await provider.get_authorization_url( | |
| state="phase6c-live-linkedin-state-value-that-is-long-enough", | |
| redirect_uri=_required("LINKEDIN_REDIRECT_URI"), | |
| additional_scopes=additional_scopes, | |
| ) | |
| parsed = urlparse(authorization_url) | |
| assert parsed.scheme == "https" | |
| assert parsed.netloc == "www.linkedin.com" | |
| assert parsed.path == "/oauth/v2/authorization" | |
| assert "code_challenge" not in parse_qs(parsed.query) | |
| discovered_type, external_id = await _identity(provider, token) | |
| assert discovered_type == account_type | |
| assert external_id | |
| finally: | |
| await provider.close() | |
| async def test_linkedin_live_authorization_code_exchange_when_supplied() -> None: | |
| """A fresh one-time browser code is optional and never required by CI.""" | |
| _require_base_configuration() | |
| code = os.getenv("LINKEDIN_LIVE_TEST_AUTHORIZATION_CODE", "").strip() | |
| if not code: | |
| pytest.skip( | |
| "LinkedIn OAuth code exchange is NOT VERIFIED; provide a fresh " | |
| "LINKEDIN_LIVE_TEST_AUTHORIZATION_CODE." | |
| ) | |
| from app.social.providers.linkedin import LinkedInProvider | |
| provider = LinkedInProvider(_settings()) | |
| try: | |
| token = await provider.exchange_code( | |
| code=code, | |
| redirect_uri=_required("LINKEDIN_REDIRECT_URI"), | |
| ) | |
| account = await provider.get_account(token) | |
| assert account["external_account_id"] | |
| finally: | |
| await provider.close() | |
| async def test_linkedin_live_text_publish_status_and_delete() -> None: | |
| _require_publish_consent() | |
| from app.social.providers.linkedin import LinkedInProvider | |
| provider = LinkedInProvider(_settings()) | |
| token = _token() | |
| account_type = os.getenv( | |
| "LINKEDIN_LIVE_TEST_ACCOUNT_TYPE", "linkedin_member" | |
| ).strip() | |
| read_scope = { | |
| "linkedin_member": "r_member_social", | |
| "linkedin_organization": "r_organization_social", | |
| }.get(account_type) | |
| if read_scope is None or read_scope not in _granted_scopes(): | |
| await provider.close() | |
| pytest.skip( | |
| "LinkedIn live status reconciliation is NOT VERIFIED; declare the " | |
| f"approved {read_scope or 'account read'} scope." | |
| ) | |
| external_id: str | None = None | |
| try: | |
| result = await _publish_text(provider, token) | |
| external_id = str(result["id"]) | |
| status: dict[str, object] | None = None | |
| for _ in range(12): | |
| status = await provider.get_publish_status(token, external_id) | |
| if status.get("status") in {"published", "failed", "deleted"}: | |
| break | |
| await asyncio.sleep(5) | |
| assert status is not None and status.get("status") == "published" | |
| finally: | |
| if external_id: | |
| await provider.delete_post(token, external_id) | |
| await provider.close() | |
| async def test_linkedin_live_media_publish_when_asset_is_supplied() -> None: | |
| _require_publish_consent() | |
| media_value = os.getenv("LINKEDIN_LIVE_TEST_MEDIA_PATH", "").strip() | |
| if not media_value: | |
| pytest.skip( | |
| "LinkedIn media publishing is NOT VERIFIED; set " | |
| "LINKEDIN_LIVE_TEST_MEDIA_PATH to a dedicated image or MP4 asset." | |
| ) | |
| from app.services.ffprobe_service import FFprobeService | |
| from app.services.validator import MediaValidator | |
| from app.social.providers.linkedin import LinkedInProvider | |
| media_path = Path(media_value).expanduser().resolve() | |
| if not media_path.is_file(): | |
| pytest.skip("LINKEDIN_LIVE_TEST_MEDIA_PATH is not a readable file.") | |
| settings = _settings() | |
| provider = LinkedInProvider(settings) | |
| token = _token() | |
| state: dict[str, object] = {} | |
| async def persist(value: dict[str, object]) -> None: | |
| state.clear() | |
| state.update(value) | |
| external_id: str | None = None | |
| try: | |
| account_type, account_id = await _identity(provider, token) | |
| probe = await FFprobeService(settings).probe(media_path) | |
| mime_type = MediaValidator(settings).infer_mime(media_path) | |
| post_type = "image" if mime_type.startswith("image/") else "video" | |
| media = { | |
| "path": media_path, | |
| "mime_type": mime_type, | |
| "file_size": media_path.stat().st_size, | |
| "probe": probe, | |
| "provider_account_id": account_id, | |
| "provider_account_type": account_type, | |
| "linkedin_post_metadata": { | |
| "post_type": post_type, | |
| "commentary": "MediaRouter Phase 6C media verification", | |
| }, | |
| "provider_state": state, | |
| "persist_provider_state": persist, | |
| } | |
| await provider.validate_media(media) | |
| uploaded = await provider.upload_media(token, media) | |
| result = await provider.publish( | |
| token, | |
| { | |
| "provider_account_id": account_id, | |
| "provider_account_type": account_type, | |
| "linkedin_post_metadata": media["linkedin_post_metadata"], | |
| "upload": uploaded, | |
| "provider_state": state, | |
| "persist_provider_state": persist, | |
| }, | |
| ) | |
| external_id = str(result["id"]) | |
| assert external_id.startswith("urn:li:") | |
| finally: | |
| if external_id: | |
| await provider.delete_post(token, external_id) | |
| await provider.close() | |
| async def test_linkedin_live_analytics_when_authorized() -> None: | |
| _require_publish_consent() | |
| from app.social.providers.linkedin import LinkedInProvider | |
| provider = LinkedInProvider(_settings()) | |
| token = _token() | |
| external_id: str | None = None | |
| try: | |
| account_type, account_id = await _identity(provider, token) | |
| required_scopes = provider.analytics_scopes(account_type) | |
| if not required_scopes or required_scopes[0] not in _granted_scopes(): | |
| pytest.skip( | |
| "LinkedIn analytics are NOT VERIFIED; the dedicated token does " | |
| "not declare the required analytics grant." | |
| ) | |
| result = await _publish_text(provider, token) | |
| external_id = str(result["id"]) | |
| metrics = await provider.get_metrics( | |
| { | |
| **token, | |
| "_mediarouter_account_type": account_type, | |
| "_mediarouter_external_account_id": account_id, | |
| }, | |
| external_id, | |
| ) | |
| assert metrics["status"] == "available" | |
| assert isinstance(metrics.get("raw_metrics"), dict) | |
| finally: | |
| if external_id: | |
| await provider.delete_post(token, external_id) | |
| await provider.close() | |