MediaRouter / tests /test_linkedin_foundation.py
basyx's picture
Upload 437 files
7cc81cb verified
Raw
History Blame Contribute Delete
21.6 kB
"""Phase 6A LinkedIn OIDC and organization-discovery coverage.
All LinkedIn traffic is mocked. Normal CI needs no developer application,
member credential, organization role, or interactive authorization flow.
"""
from __future__ import annotations
from datetime import datetime, timedelta, timezone
from pathlib import Path
from urllib.parse import parse_qs, urlparse
import httpx
import pytest
from pydantic import ValidationError
from sqlalchemy import func, select
from app.container import build_container
from app.core.config import Settings
from app.social.domain.errors import (
SocialAccountNotFoundError,
SocialCapabilityUnsupportedError,
SocialOAuthStateError,
SocialPermissionDeniedError,
SocialReauthRequiredError,
)
from app.social.models import OAuthState, SocialAccountToken
from app.social.providers.linkedin import LINKEDIN_API_VERSION, LinkedInProvider
from app.social.schemas.accounts import SocialAccountConnectRequest
_REDIRECT_URI = "https://api.example.com/v1/social/accounts/linkedin/callback"
def linkedin_settings(tmp_path: Path) -> Settings:
return Settings(
_env_file=None,
auth_enabled=False,
database_url=f"sqlite+aiosqlite:///{tmp_path / 'security.db'}",
social_database_url=f"sqlite+aiosqlite:///{tmp_path / 'social.db'}",
social_auto_migrate=True,
social_worker_enabled=False,
social_oauth_encryption_key="phase-6a-linkedin-test-encryption-material",
social_oauth_redirect_base_url="https://api.example.com",
linkedin_client_id="linkedin-client-id",
linkedin_client_secret="linkedin-client-secret",
linkedin_redirect_uri=_REDIRECT_URI,
temp_dir=tmp_path / "temp",
output_dir=tmp_path / "outputs",
cleanup_interval_seconds=3600,
whisper_model="tiny",
)
async def test_linkedin_member_authorization_uses_official_oidc_without_pkce(
tmp_path: Path,
) -> None:
provider = LinkedInProvider(linkedin_settings(tmp_path))
try:
url = await provider.get_authorization_url(
state="s" * 43,
redirect_uri=_REDIRECT_URI,
)
with pytest.raises(SocialPermissionDeniedError):
await provider.get_authorization_url(
state="s" * 43,
redirect_uri=_REDIRECT_URI,
code_challenge="undocumented-pkce-challenge",
)
with pytest.raises(SocialCapabilityUnsupportedError):
await provider.get_authorization_url(
state="s" * 43,
redirect_uri=_REDIRECT_URI,
additional_scopes=["w_member_social"],
)
finally:
await provider.close()
parsed = urlparse(url)
query = parse_qs(parsed.query)
assert f"{parsed.scheme}://{parsed.netloc}{parsed.path}" == (
"https://www.linkedin.com/oauth/v2/authorization"
)
assert query == {
"client_id": ["linkedin-client-id"],
"redirect_uri": [_REDIRECT_URI],
"response_type": ["code"],
"state": ["s" * 43],
"scope": ["openid profile"],
}
assert "code_challenge" not in query
async def test_linkedin_exchange_member_and_organization_discovery_use_official_apis(
tmp_path: Path,
) -> None:
calls: list[str] = []
async def handler(request: httpx.Request) -> httpx.Response:
calls.append(request.url.path)
if request.url.path == "/oauth/v2/accessToken":
assert request.url.host == "www.linkedin.com"
form = parse_qs(request.content.decode())
assert form == {
"grant_type": ["authorization_code"],
"code": ["authorization-code"],
"redirect_uri": [_REDIRECT_URI],
"client_id": ["linkedin-client-id"],
"client_secret": ["linkedin-client-secret"],
}
return httpx.Response(
200,
json={
"access_token": "linkedin-access-token",
"expires_in": 5184000,
"token_type": "Bearer",
},
)
assert request.headers["authorization"] == "Bearer linkedin-access-token"
if request.url.path == "/v2/userinfo":
return httpx.Response(
200,
json={
"sub": "oidc-member-subject_123",
"name": "Ada Lovelace",
"given_name": "Ada",
"family_name": "Lovelace",
"picture": "https://media.licdn.com/member.jpg",
"locale": {"country": "US", "language": "en"},
"email": "not-persisted@example.com",
"email_verified": True,
},
)
assert request.headers["linkedin-version"] == LINKEDIN_API_VERSION
assert request.headers["x-restli-protocol-version"] == "2.0.0"
if request.url.path == "/rest/organizationAcls":
assert parse_qs(request.url.query.decode()) == {
"q": ["roleAssignee"],
"role": ["ADMINISTRATOR"],
"state": ["APPROVED"],
"count": ["100"],
"start": ["0"],
}
return httpx.Response(
200,
json={
"elements": [
{"organization": "urn:li:organization:123456"},
{
"organizationTarget": "urn:li:organization:789012"
},
],
"paging": {"start": 0, "count": 2, "total": 2, "links": []},
},
)
organization_id = request.url.path.rsplit("/", 1)[-1]
return httpx.Response(
200,
json={
"id": int(organization_id),
"localizedName": f"Organization {organization_id}",
"vanityName": f"organization-{organization_id}",
"logoV2": {
"digitalmediaAsset": "urn:li:digitalmediaAsset:logo_asset",
"original~": {
"elements": [
{
"identifiers": [
{
"identifier": f"https://media.licdn.com/{organization_id}.png"
}
]
}
]
},
},
"primaryOrganizationType": "NONE",
"defaultLocale": {"country": "US", "language": "en"},
"localizedWebsite": "https://example.com",
},
)
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
provider = LinkedInProvider(linkedin_settings(tmp_path), http_client=client)
try:
token = await provider.exchange_code(
code="authorization-code",
redirect_uri=_REDIRECT_URI,
)
accounts = await provider.discover_accounts(
token, account_type="linkedin_organization"
)
finally:
await client.aclose()
member, first, second = accounts
assert member["external_account_id"] == "oidc-member-subject_123"
assert member["account_type"] == "linkedin_member"
assert member["connection_status"] == "connected"
assert member["metadata"]["email_verified"] is True
assert "email" not in member["metadata"]
assert [first["external_account_id"], second["external_account_id"]] == [
"123456",
"789012",
]
assert first["account_type"] == "linkedin_organization"
assert first["connection_status"] == "pending"
assert first["avatar_url"] == "https://media.licdn.com/123456.png"
assert first["metadata"]["parent_member_id"] == "oidc-member-subject_123"
assert first["metadata"]["logo_asset"] == (
"urn:li:digitalmediaAsset:logo_asset"
)
assert calls == [
"/oauth/v2/accessToken",
"/v2/userinfo",
"/rest/organizationAcls",
"/rest/organizations/123456",
"/rest/organizations/789012",
]
async def test_linkedin_organization_scope_is_explicit_and_bound_to_state(
tmp_path: Path,
) -> None:
container = build_container(linkedin_settings(tmp_path))
await container.social.initialize()
try:
member = await container.social.oauth.connect(
provider="linkedin",
workspace_id="workspace-a",
user_id="user-a",
payload=SocialAccountConnectRequest(),
)
member_query = parse_qs(urlparse(member.authorization_url or "").query)
assert member_query["scope"] == ["openid profile"]
assert "code_challenge" not in member_query
organization = await container.social.oauth.connect(
provider="linkedin",
workspace_id="workspace-a",
user_id="user-a",
payload=SocialAccountConnectRequest(
account_type="linkedin_organization"
),
)
organization_query = parse_qs(
urlparse(organization.authorization_url or "").query
)
assert organization_query["scope"] == [
"openid profile rw_organization_admin"
]
assert "w_organization_social" not in organization_query["scope"][0]
state = await container.social.oauth.states.consume(
state=organization_query["state"][0], provider="linkedin"
)
assert state.workspace_id == "workspace-a"
assert state.user_id == "user-a"
assert state.requested_account_type == "linkedin_organization"
assert state.requested_scopes == [
"openid",
"profile",
"rw_organization_admin",
]
with pytest.raises(SocialCapabilityUnsupportedError):
await container.social.oauth.connect(
provider="linkedin",
workspace_id="workspace-a",
user_id="user-a",
payload=SocialAccountConnectRequest(account_type="organization"),
)
finally:
await container.social.close()
await container.security_database.close()
async def test_linkedin_callback_is_duplicate_safe_selectable_and_workspace_bound(
tmp_path: Path,
) -> None:
container = build_container(linkedin_settings(tmp_path))
await container.social.initialize()
adapter = container.social.accounts.providers.get("linkedin")
assert isinstance(adapter, LinkedInProvider)
await adapter._client.aclose()
async def handler(request: httpx.Request) -> httpx.Response:
if request.url.path == "/oauth/v2/accessToken":
return httpx.Response(
200,
json={
"access_token": "linkedin-token-that-must-remain-encrypted",
"expires_in": 3600,
"token_type": "Bearer",
},
)
if request.url.path == "/v2/userinfo":
return httpx.Response(
200,
json={"sub": "stable-member-sub", "name": "Workspace Member"},
)
if request.url.path == "/rest/organizationAcls":
return httpx.Response(
200,
json={
"elements": [
{"organization": "urn:li:organization:123456"}
],
"paging": {"start": 0, "count": 1, "total": 1},
},
)
return httpx.Response(
200,
json={
"id": 123456,
"localizedName": "Workspace Organization",
"vanityName": "workspace-organization",
},
)
adapter._client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
adapter._owns_client = True
try:
connect = await container.social.oauth.connect(
provider="linkedin",
workspace_id="workspace-a",
user_id="user-a",
payload=SocialAccountConnectRequest(
account_type="linkedin_organization"
),
)
state = parse_qs(urlparse(connect.authorization_url or "").query)["state"][0]
member = await container.social.oauth.callback(
provider="linkedin", state=state, code="first-code"
)
assert member.account_type == "linkedin_member"
assert member.status.value == "connected"
with pytest.raises(SocialOAuthStateError):
await container.social.oauth.callback(
provider="linkedin", state=state, code="replayed-code"
)
accounts = await container.social.accounts.list("workspace-a")
assert len(accounts) == 2
organization = next(
item
for item in accounts
if item.account_type == "linkedin_organization"
)
assert organization.status.value == "pending"
assert "linkedin-token-that-must-remain-encrypted" not in (
organization.model_dump_json()
)
with pytest.raises(SocialAccountNotFoundError):
await container.social.accounts.select_discovered(
"workspace-b", [organization.id]
)
selected = await container.social.accounts.select_discovered(
"workspace-a", [organization.id, organization.id]
)
assert len(selected) == 1
assert selected[0].status.value == "connected"
second_connect = await container.social.oauth.connect(
provider="linkedin",
workspace_id="workspace-a",
user_id="user-a",
payload=SocialAccountConnectRequest(
account_type="linkedin_organization"
),
)
second_state = parse_qs(
urlparse(second_connect.authorization_url or "").query
)["state"][0]
await container.social.oauth.callback(
provider="linkedin", state=second_state, code="second-code"
)
duplicate_safe = await container.social.accounts.list("workspace-a")
assert len(duplicate_safe) == 2
assert next(
item
for item in duplicate_safe
if item.account_type == "linkedin_organization"
).status.value == "connected"
async with container.social.database.session("workspace-a") as session:
token_count = await session.scalar(select(func.count(SocialAccountToken.id)))
encrypted_payloads = list(
(
await session.scalars(
select(SocialAccountToken.encrypted_payload)
)
).all()
)
assert token_count == 2
assert all(encrypted_payloads)
assert all(
"linkedin-token-that-must-remain-encrypted" not in str(payload)
for payload in encrypted_payloads
)
finally:
await container.social.close()
await container.security_database.close()
async def test_linkedin_state_redirect_expiry_and_provider_binding(
tmp_path: Path,
) -> None:
container = build_container(linkedin_settings(tmp_path))
await container.social.initialize()
try:
assert container.social.oauth._redirect_uri("linkedin", None) == _REDIRECT_URI
with pytest.raises(SocialPermissionDeniedError):
container.social.oauth._redirect_uri(
"linkedin",
"https://attacker.example/v1/social/accounts/linkedin/callback",
)
state = await container.social.oauth.states.create(
provider="linkedin",
workspace_id="workspace-a",
user_id="user-a",
redirect_uri=_REDIRECT_URI,
requested_account_type="linkedin_member",
requested_scopes=["openid", "profile"],
)
with pytest.raises(SocialOAuthStateError):
await container.social.oauth.states.consume(
state=state.state, provider="x"
)
consumed = await container.social.oauth.states.consume(
state=state.state, provider="linkedin"
)
assert consumed.workspace_id == "workspace-a"
expired = OAuthState(
state="expired-linkedin-state-value-that-is-long-enough",
provider="linkedin",
workspace_id="workspace-a",
user_id="user-a",
redirect_uri=_REDIRECT_URI,
requested_account_type="linkedin_member",
requested_scopes=["openid", "profile"],
expires_at=datetime.now(timezone.utc) - timedelta(seconds=1),
)
async with container.social.database.session("workspace-a") as session:
session.add(expired)
await session.commit()
with pytest.raises(SocialOAuthStateError):
await container.social.oauth.states.consume(
state=expired.state, provider="linkedin"
)
finally:
await container.social.close()
await container.security_database.close()
async def test_linkedin_invalid_code_and_refresh_rules_do_not_leak_secrets(
tmp_path: Path,
) -> None:
secret_code = "linkedin-code-that-must-not-leak"
async def handler(_: httpx.Request) -> httpx.Response:
return httpx.Response(
400,
json={
"error": "invalid_grant",
"error_description": f"bad code {secret_code}",
},
)
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
provider = LinkedInProvider(linkedin_settings(tmp_path), http_client=client)
try:
with pytest.raises(SocialPermissionDeniedError) as raised:
await provider.exchange_code(
code=secret_code, redirect_uri=_REDIRECT_URI
)
with pytest.raises(SocialReauthRequiredError):
await provider.refresh_token({"access_token": "expired"})
finally:
await client.aclose()
assert secret_code not in str(raised.value)
assert "linkedin-client-secret" not in str(raised.value)
async def test_linkedin_refresh_is_used_only_when_provider_issued_it(
tmp_path: Path,
) -> None:
async def handler(request: httpx.Request) -> httpx.Response:
form = parse_qs(request.content.decode())
assert form == {
"grant_type": ["refresh_token"],
"refresh_token": ["partner-refresh-token"],
"client_id": ["linkedin-client-id"],
"client_secret": ["linkedin-client-secret"],
}
return httpx.Response(
200,
json={
"access_token": "refreshed-access-token",
"expires_in": 5184000,
"token_type": "Bearer",
},
)
client = httpx.AsyncClient(transport=httpx.MockTransport(handler))
provider = LinkedInProvider(linkedin_settings(tmp_path), http_client=client)
try:
refreshed = await provider.refresh_token(
{
"access_token": "expired-access-token",
"refresh_token": "partner-refresh-token",
}
)
finally:
await client.aclose()
assert refreshed["access_token"] == "refreshed-access-token"
assert refreshed["refresh_token"] == "partner-refresh-token"
async def test_linkedin_capabilities_are_discovery_only(tmp_path: Path) -> None:
container = build_container(linkedin_settings(tmp_path))
try:
linkedin = container.social.accounts.get_provider("linkedin")
assert linkedin.available
assert linkedin.configured
assert linkedin.capabilities.implementation_status == "implemented"
assert linkedin.capabilities.account_types == [
"linkedin_member",
"linkedin_organization",
]
assert linkedin.capabilities.required_scopes == ["openid", "profile"]
assert linkedin.capabilities.optional_scopes == ["rw_organization_admin"]
assert not linkedin.capabilities.video
assert not linkedin.capabilities.image
assert not linkedin.capabilities.direct_publish
assert not linkedin.capabilities.draft_upload
assert not linkedin.capabilities.scheduled_publish
assert not linkedin.capabilities.analytics
assert not linkedin.capabilities.delete_post
assert not linkedin.capabilities.personal_publishing
assert not linkedin.capabilities.organization_publishing
assert linkedin.capabilities.publish_metadata_schema == {}
finally:
await container.social.close()
await container.security_database.close()
def test_linkedin_redirect_configuration_is_fail_closed() -> None:
invalid = [
"https://attacker.example/not-the-linkedin-callback",
"http://api.example.com/v1/social/accounts/linkedin/callback",
"https://api.example.com/v1/social/accounts/linkedin/callback?next=bad",
"ftp://localhost/v1/social/accounts/linkedin/callback",
]
for redirect in invalid:
with pytest.raises(ValidationError):
Settings(_env_file=None, linkedin_redirect_uri=redirect)
local = Settings(
_env_file=None,
linkedin_redirect_uri=(
"http://localhost/v1/social/accounts/linkedin/callback"
),
)
assert local.linkedin_redirect_uri.startswith("http://localhost/")