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
23 changes: 15 additions & 8 deletions main.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,9 @@ def get_site_list(username: str) -> list[SiteData]:
]


async def confirm_profile_exists(url: str, username: str, title: str) -> bool:
async def confirm_profile_exists(
url: str, username: str, title: str
) -> tuple[bool, XValidationResponse | None]:
"""
For some sites, a HEAD request may return 200 OK for both existing and non-existing profiles.
In such cases, we need to perform a GET request to confirm the existence of the profile.
Expand All @@ -86,18 +88,18 @@ async def confirm_profile_exists(url: str, username: str, title: str) -> bool:
SSLError,
) as e:
logger.warning(f"Profile validation failed for '{username}' at '{url}': {e}")
return False
return False, None
except Exception as e:
logger.exception(
f"Unexpected error during profile validation for '{username}' at '{url}': {e}"
)
return False
return False, None

if response.status_code != HTTPStatus.OK:
logger.debug(
f"Profile validation GET returned {response.status_code} for {url}"
)
return False
return False, None

logger.info(
f"Checking profile existence for '{username}' on {url} with title '{title}'"
Expand All @@ -111,12 +113,16 @@ async def confirm_profile_exists(url: str, username: str, title: str) -> bool:
logger.warning(
f"Invalid validation response for '{username}' on '{url}': {e}"
)
return False
return False, None

# if username is not available because it's taken then profile exists
return not validation.valid and validation.reason == XUsernameAvailabilityReason.taken
return (
not validation.valid
and validation.reason == XUsernameAvailabilityReason.taken,
validation,
)

return False
return False, None

async def search_for_username(username: str) -> list[SiteResult]:
"""
Expand All @@ -135,7 +141,7 @@ async def run_site(site_data: SiteData):
response = await client.head(validation_uri, follow_redirects=True)

if response.status_code == HTTPStatus.OK:
is_profile = await confirm_profile_exists(
is_profile, validation = await confirm_profile_exists(
validation_uri, username, site_data.title
)
if not is_profile:
Expand All @@ -149,6 +155,7 @@ async def run_site(site_data: SiteData):
profile_uri=site_data.profile_uri,
validation_uri=site_data.validation_uri,
is_valid_profile=is_profile,
validation=validation,
)
logger.debug(f"Username '{username}' found on site: {validation_uri}")
return site_result
Expand Down
4 changes: 4 additions & 0 deletions models.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,7 @@ class SiteData(BaseModel):

class SiteResult(SiteData):
is_valid_profile: bool = Field(..., description="True when the profile exists")
validation: XValidationResponse | None = Field(
default=None,
description="Public validation evidence returned by the platform endpoint",
)
45 changes: 45 additions & 0 deletions tests/test_validation_evidence.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import asyncio
import sys
from http import HTTPStatus
from pathlib import Path

ROOT = Path(__file__).resolve().parents[2]
if str(ROOT) not in sys.path:
sys.path.insert(0, str(ROOT))

from profile_checker import main


class FakeResponse:
status_code = HTTPStatus.OK

@staticmethod
def json():
return {
"valid": False,
"reason": "taken",
"msg": "Username has already been taken",
"desc": "That username has been taken. Please choose another.",
}


class FakeClient:
async def get(self, *_args, **_kwargs):
return FakeResponse()


def test_x_validation_evidence_is_returned(monkeypatch):
monkeypatch.setattr(main, "client", FakeClient())

exists, evidence = asyncio.run(
main.confirm_profile_exists(
"https://api.x.com/i/users/username_available.json?username=example",
"example",
"X",
)
)

assert exists is True
assert evidence.reason.value == "taken"
assert evidence.msg == "Username has already been taken"
assert evidence.desc == "That username has been taken. Please choose another."
Loading