Skip to content

Commit

Permalink
Add browser automation for registering passkeys
Browse files Browse the repository at this point in the history
  • Loading branch information
Stormheg committed Nov 29, 2024
1 parent abec71d commit adc3197
Show file tree
Hide file tree
Showing 6 changed files with 454 additions and 0 deletions.
1 change: 1 addition & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ testing = [
"pytest-mock>=3.14,<4",
"jsonschema ~= 4.23",
]
playwright = ["pytest-playwright>=0.5.2,<1", "playwright>=1.49,<2"]

[project.urls]
# TODO: documentation link
Expand Down
17 changes: 17 additions & 0 deletions run_e2e_testsuite.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#!/bin/sh
set -e

# This script is used to install and run the e2e testsuite.
# Usage:
#
# Run the testsuite:
# ./run_e2e_testsuite.sh
#
# Run a specific test:
# ./run_e2e_testsuite.sh -k test_my_test_name
#
# Run all tests with tracing enabled:
# ./run_e2e_testsuite.sh --tracing on

playwright install chromium
DJANGO_ALLOW_ASYNC_UNSAFE=1 python -m pytest --browser chromium --headed tests/e2e/ $@
Empty file added tests/e2e/__init__.py
Empty file.
113 changes: 113 additions & 0 deletions tests/e2e/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
import random
from concurrent.futures import Future

import pytest
from django.test.client import Client as DjangoTestClient
from playwright.sync_api import CDPSession

from tests.e2e.fixtures import VirtualAuthenticator


class FutureWrapper:
def __init__(self):
self.future = Future()

def get_result(self):
return self.future.result(timeout=5)

def set_result(self, value):
self.future.set_result(value)


@pytest.fixture
def event_waiter():
def _event_waiter():
future = FutureWrapper()
return future.get_result, future.set_result

return _event_waiter


@pytest.fixture(scope="function")
def cdpsession(page) -> CDPSession:
return page.context.new_cdp_session(page)


@pytest.fixture(autouse=True)
def configure_otp_webauthn(settings, live_server):
settings.OTP_WEBAUTHN_RP_NAME = "OTP WebAuthn Testsuite"
settings.OTP_WEBAUTHN_RP_ID = "localhost"
settings.OTP_WEBAUTHN_ALLOWED_ORIGINS = [live_server.url]


@pytest.fixture
def wait_for_javascript_event(page):
"""Returns a function that blocks until a JavaScript event has been fired."""

def _wait_for_javascript_event(event_name: str):
# Hot mess - why is there no Playwright API for this?

# We need to generate a unique name for the nook to store the event result in
nook = "".join([chr(97 + random.randint(0, 25)) for _ in range(25)])

# Setup the listener, that stores the event detail in the nook
page.evaluate(f"""
window._event_{nook} = undefined;
document.addEventListener("{event_name}", event => {{
console.log("Event fired", event);
window._event_{nook} = event.detail;
}});
""")

# Return a function that will block until the event has been fired and
# the detail has been stored in the nook. It is indirect like this
# because we circumvent the Content Security Policy that is active for
# the sandbox.
def _return():
page.wait_for_function(
"payload => payload !== undefined",
arg="window._event_" + nook,
timeout=5000,
)
return page.evaluate("window._event_" + nook)

return _return

return _wait_for_javascript_event


@pytest.fixture
def virtual_authenticator(cdpsession):
def _get_authenticator(authenticator: VirtualAuthenticator):
cdpsession.send("WebAuthn.enable")
resp = cdpsession.send(
"WebAuthn.addVirtualAuthenticator",
{
"options": authenticator.as_cdp_options(),
},
)
return resp

return _get_authenticator


@pytest.fixture
def playwright_force_login(live_server, context):
"""Fixture that forces the given user to be logged in by manipulating the session cookie."""

def _playwright_force_login(user):
login_helper = DjangoTestClient()
login_helper.force_login(user)

context.add_cookies(
[
{
"url": live_server.url,
"name": "sessionid",
"value": login_helper.cookies["sessionid"].value,
}
]
)
return user

return _playwright_force_login
65 changes: 65 additions & 0 deletions tests/e2e/fixtures.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import enum
from dataclasses import dataclass


# Matches StatusEnum in types.ts
class StatusEnum(enum.StrEnum):
UNKNOWN_ERROR = "unknown-error"
STATE_ERROR = "state-error"
SECURITY_ERROR = "security-error"
GET_OPTIONS_FAILED = ("get-options-failed",)
ABORTED = "aborted"
NOT_ALLOWED_OR_ABORTED = "not-allowed-or-aborted"
SERVER_ERROR = "server-error"
SUCCESS = "success"
BUSY = "busy"


@dataclass(frozen=True)
class VirtualAuthenticator:
transport: str
protocol: str
has_resident_key: bool
has_user_verification: bool
is_user_verified: bool
default_backup_eligibility: bool
default_backup_state: bool
automaticPresenceSimulation: bool

def as_cdp_options(self) -> dict:
return {
"transport": self.transport,
"protocol": self.protocol,
"hasResidentKey": self.has_resident_key,
"hasUserVerification": self.has_user_verification,
"isUserVerified": self.is_user_verified,
"defaultBackupEligibility": self.default_backup_eligibility,
"defaultBackupState": self.default_backup_state,
"automaticPresenceSimulation": self.automaticPresenceSimulation,
}

@classmethod
def internal(cls):
return cls(
transport="internal",
protocol="ctap2",
has_resident_key=True,
has_user_verification=True,
is_user_verified=True,
default_backup_eligibility=True,
default_backup_state=False,
automaticPresenceSimulation=True,
)

@classmethod
def u2f(cls):
return cls(
transport="usb",
protocol="u2f",
has_resident_key=False,
has_user_verification=False,
is_user_verified=False,
default_backup_eligibility=False,
default_backup_state=False,
automaticPresenceSimulation=True,
)
Loading

0 comments on commit adc3197

Please sign in to comment.