diff --git a/.gitignore b/.gitignore index 14392c1..c577dc1 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,7 @@ target/ node_modules/ +__pycache__/ +.pytest_cache/ # State database.db* diff --git a/Cargo.lock b/Cargo.lock index 2380546..444c653 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1052,9 +1052,9 @@ dependencies = [ [[package]] name = "futures" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a88cf1f829d945f548cf8fec32c61b1f202b6d93b45848602fc02af4b12ad218" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -1067,9 +1067,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "262590f4fe6afeb0bc83be1daa64e52657fe185690a958af7f3ad0e92085c5ae" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -1077,15 +1077,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2cd50c473c80f6d7c3670a752354b8e569b1a7cbfdc0419ec88e5edad85e0dc7" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6754879cc9f2c66f88c6e5c35344bb0bdb0708b0352b1201815667c7eabc7458" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -1105,38 +1105,38 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4577ecaa3c4f96589d473f679a71b596316f6641bc350038b962a5daf0085d7a" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6d3cde68c518367be28956066ddfef33813991b77a55005a69dae04bf3b10b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.3", ] [[package]] name = "futures-sink" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e34418ac499d6305c2fb5ad0ed2f6ac998c5f8ca209b4510f7f94242c647e307" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b231ed28831efb4a61a08580c4bc233ec56bc009f4cd8f52da2c3cb97df0c109" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a77a90a256fce34da66415271e30f94ee91c57b04b8a2c042d9cf3220179deaa" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", diff --git a/flake.nix b/flake.nix index 075b651..43ea91e 100644 --- a/flake.nix +++ b/flake.nix @@ -87,6 +87,14 @@ dontNpmBuild = true; meta.mainProgram = "starhaven-dev-pds"; }; + + # For tests/, a Playwright-driven E2E suite. + pythonEnv = pkgs.python3.withPackages (ps: [ + ps.pytest + ps.pytest-playwright + ps.playwright + ps.requests + ]); in { _module.args.pkgs = import inputs.nixpkgs { @@ -132,6 +140,7 @@ pkgs.sqlx-cli pkgs.just pkgs.watchexec + pythonEnv tap ] ++ lib.optionals useMold [ pkgs.mold ]; @@ -139,6 +148,10 @@ # For rust-analyzer 'hover' tooltips to work. export RUST_SRC_PATH="${devToolchain}/lib/rustlib/src/rust/library"; + # Browsers for tests/'s Playwright suite. + export PLAYWRIGHT_BROWSERS_PATH="${pkgs.playwright-driver.browsers}"; + export PLAYWRIGHT_SKIP_BROWSER_DOWNLOAD=1; + # Local PDS + PLC directory runs in the background dev_pds_pidfile=/tmp/starhaven-dev-pds.pid if ! kill -0 "$(cat "$dev_pds_pidfile" 2>/dev/null)" 2>/dev/null; then diff --git a/justfile b/justfile index 1f46990..6688d83 100644 --- a/justfile +++ b/justfile @@ -15,6 +15,7 @@ debug *ARGS: test: cargo test + pytest tests/ check: cargo clippy --all-targets diff --git a/src/atproto/mod.rs b/src/atproto/mod.rs index 6d69a76..0f92b4c 100644 --- a/src/atproto/mod.rs +++ b/src/atproto/mod.rs @@ -8,4 +8,5 @@ pub mod actor; pub mod id; pub mod lexicon; +pub mod pds; pub mod tap; diff --git a/src/atproto/pds.rs b/src/atproto/pds.rs new file mode 100644 index 0000000..58d62a2 --- /dev/null +++ b/src/atproto/pds.rs @@ -0,0 +1,45 @@ +//! Authenticated writes to a user's own PDS. + +use anyhow::{bail, Result}; +use atproto_client::client::{Auth, DPoPAuth}; +use atproto_client::com::atproto::repo::{put_record, PutRecordRequest, PutRecordResponse}; +use atproto_identity::key::identify_key; +use serde_json::Value; + +use crate::oauth::session::SessionCookie; +use crate::state::AppState; + +/// `com.atproto.repo.putRecord`, authenticated as `session`'s owner. +pub async fn put( + state: &AppState, + session: &SessionCookie, + collection: &str, + rkey: &str, + record: Value, +) -> Result<()> { + let dpop_auth = DPoPAuth { + dpop_private_key_data: identify_key(&session.dpop_private_key)?, + oauth_access_token: session.access_token.clone(), + }; + + let response = put_record( + &state.http_client, + &Auth::DPoP(dpop_auth), + &session.pds_endpoint, + PutRecordRequest { + repo: session.did.to_string(), + collection: collection.to_string(), + record_key: rkey.to_string(), + validate: false, + record, + swap_commit: None, + swap_record: None, + }, + ) + .await?; + + match response { + PutRecordResponse::StrongRef { .. } => Ok(()), + PutRecordResponse::Error(err) => bail!("PDS rejected the record: {err:?}"), + } +} diff --git a/src/config.rs b/src/config.rs index 5e62564..0a540b5 100644 --- a/src/config.rs +++ b/src/config.rs @@ -98,9 +98,13 @@ impl Config { } } - /// The OAuth redirect URI. + /// The OAuth redirect URI. RFC 8252 forbids a `localhost` hostname here + /// (only a literal loopback IP), even though `external_base` accepts + /// `localhost` fine everywhere else - we bind `127.0.0.1` regardless, so + /// substituting it in doesn't change what actually gets served. pub fn oauth_redirect_uri(&self) -> String { - format!("{}/callback", self.external_base()) + let base = self.external_base().replacen("localhost", "127.0.0.1", 1); + format!("{base}/callback") } /// The JWKS URI. @@ -145,6 +149,18 @@ mod tests { ))); } + #[test] + fn redirect_uri_never_uses_localhost_hostname() { + // RFC 8252: the OAuth authorization server rejects a "localhost" + // redirect_uri outright, even though "localhost" is a fine + // `--external-base` for a human to type. + let config = config_with_base("http://localhost:3000"); + assert_eq!( + config.oauth_redirect_uri(), + "http://127.0.0.1:3000/callback" + ); + } + #[test] fn non_loopback_uses_client_metadata_url() { let config = config_with_base("https://starhaven.dev"); diff --git a/src/mods.rs b/src/mods.rs index a82a320..061786a 100644 --- a/src/mods.rs +++ b/src/mods.rs @@ -41,41 +41,41 @@ fn create_form() -> Markup { h2.step-title { "General" } p.step-description { "Basic mod listing information." } div.form-item { - label { "Title" } - input type="text" name="title" required; + label for="title" { "Title" } + input type="text" id="title" name="title" required; } div.form-item { - label { "Slug" } - input type="text" name="slug" required; + label for="slug" { "Slug" } + input type="text" id="slug" name="slug" required; p.hint { "TODO prefill or generate from title" } } div.form-item { - label { "Description" } - input type="text" name="description" required; + label for="description" { "Description" } + input type="text" id="description" name="description" required; } } div.step { h2.step-title { "Details" } p.step-description { "Extended information about the mod." } div.form-item { - label { "Game" } - select name="game" required { + label for="game" { "Game" } + select id="game" name="game" required { @for g in Game::ALL { option value=(g.as_str()) { (g) } } } } div.form-item { - label { "Details" } - textarea name="details" {} + label for="details" { "Details" } + textarea id="details" name="details" {} } div.form-item { - label { "Category" } - input type="text" name="category"; + label for="category" { "Category" } + input type="text" id="category" name="category"; } div.form-item { - label { "License" } - input type="text" name="license"; + label for="license" { "License" } + input type="text" id="license" name="license"; } } div.form-actions { @@ -128,8 +128,14 @@ async fn create( listing .validate() .map_err(|e| AppError::BadRequest(e.to_string()))?; + + let rkey = Rkey::new(); + let record = listing.to_value().map_err(AppError::Internal)?; + crate::atproto::pds::put(&state, &session, ModListing::NSID, rkey.as_str(), record) + .await + .map_err(AppError::Internal)?; listing - .save(&state.db, &session.did, &Rkey::new()) + .save(&state.db, &session.did, &rkey) .await .map_err(AppError::Internal)?; @@ -186,32 +192,32 @@ fn edit_form(handle: &Handle, slug: &str, listing: &ModListing) -> Markup { html! { div.centered { form.bg-pink method="post" action={ "/" (handle) "/" (slug) } { div.form-item { - label { "Title" } - input type="text" name="title" value=(listing.title) required; + label for="title" { "Title" } + input type="text" id="title" name="title" value=(listing.title) required; } div.form-item { - label { "Description" } - textarea name="description" required { (listing.description) } + label for="description" { "Description" } + textarea id="description" name="description" required { (listing.description) } } div.form-item { - label { "Details" } - textarea name="details" { (listing.details) } + label for="details" { "Details" } + textarea id="details" name="details" { (listing.details) } } div.form-item { - label { "Game" } - select name="game" required { + label for="game" { "Game" } + select id="game" name="game" required { @for g in Game::ALL { option value=(g.as_str()) selected[*g == listing.game] { (g) } } } } div.form-item { - label { "Category" } - input type="text" name="category" value=(listing.category); + label for="category" { "Category" } + input type="text" id="category" name="category" value=(listing.category); } div.form-item { - label { "License" } - input type="text" name="license" value=(listing.license); + label for="license" { "License" } + input type="text" id="license" name="license" value=(listing.license); } div.form-actions { button.button type="submit" { "Save" } @@ -274,6 +280,11 @@ async fn save( listing .validate() .map_err(|e| AppError::BadRequest(e.to_string()))?; + + let record = listing.to_value().map_err(AppError::Internal)?; + crate::atproto::pds::put(&state, &session, ModListing::NSID, rkey.as_str(), record) + .await + .map_err(AppError::Internal)?; listing .save(&state.db, &did, &rkey) .await diff --git a/src/oauth/callback.rs b/src/oauth/callback.rs index 3653131..9e604eb 100644 --- a/src/oauth/callback.rs +++ b/src/oauth/callback.rs @@ -198,6 +198,7 @@ pub async fn callback( refresh_token: token_response.refresh_token.clone(), expires_at, dpop_private_key: persisted.dpop_private_key.clone(), + pds_endpoint: pds_endpoint.clone(), }; let hint_verified = document.as_ref().is_some_and(|d| d.id == did.as_str()); diff --git a/src/oauth/refresh.rs b/src/oauth/refresh.rs index 20e70bc..e4c9578 100644 --- a/src/oauth/refresh.rs +++ b/src/oauth/refresh.rs @@ -119,6 +119,7 @@ pub async fn try_refresh_session( .or(session.refresh_token.clone()), expires_at, dpop_private_key: session.dpop_private_key.clone(), + pds_endpoint: session.pds_endpoint.clone(), }; let encoded = encode_session_cookie(&state.secrets.cookie_secret, &new_session) diff --git a/src/oauth/session.rs b/src/oauth/session.rs index cfeb5a4..53dbb7f 100644 --- a/src/oauth/session.rs +++ b/src/oauth/session.rs @@ -34,6 +34,8 @@ pub struct SessionCookie { pub expires_at: DateTime, /// The per-session DPoP private key (`did:key:...`). pub dpop_private_key: String, + /// The user's PDS base URL, for authenticated writes. + pub pds_endpoint: String, } impl SessionCookie { @@ -256,6 +258,7 @@ mod tests { refresh_token: Some("refresh-token".to_string()), expires_at: Utc::now() + Duration::minutes(10), dpop_private_key: "did:key:example".to_string(), + pds_endpoint: "https://pds.example".to_string(), }; let encoded = encode_session_cookie(&secret, &session).unwrap(); @@ -277,6 +280,7 @@ mod tests { refresh_token: None, expires_at: Utc::now(), dpop_private_key: "did:key:example".to_string(), + pds_endpoint: "https://pds.example".to_string(), }; let encoded = encode_session_cookie(&secret, &session).unwrap(); diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..2975b0a --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,98 @@ +"""Fixtures for end-to-end tests.""" + +import socket +import subprocess +import tempfile +import time +from pathlib import Path + +import pytest +import requests +from playwright.sync_api import Page, expect + +REPO_ROOT = Path(__file__).resolve().parent.parent +DEV_PDS_URL = "http://localhost:2583" + + +def login(page: Page, appview_url: str, handle: str = "alice.test", password: str = "hunter2") -> None: + page.goto(f"{appview_url}/login") + page.get_by_label("Handle").fill(handle) + page.get_by_role("button", name="Login").click() + + # PDS sign in + page.get_by_role("textbox", name="Password").fill(password) + page.get_by_role("button", name="Sign in").click() + page.get_by_role("button", name="Authorize").click() + + expect(page).to_have_url(f"{appview_url}/") + + +def _free_port() -> int: + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + s.bind(("127.0.0.1", 0)) + return s.getsockname()[1] + + +def _wait_until_ready(base_url: str, timeout: float = 10.0) -> None: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + try: + if requests.get(base_url, timeout=1).status_code == 200: + return + except requests.RequestException: + pass + time.sleep(0.1) + raise TimeoutError(f"{base_url} did not become ready in time") + + +@pytest.fixture(scope="session") +def starhaven_binary() -> Path: + subprocess.run(["cargo", "build", "--bin", "server"], cwd=REPO_ROOT, check=True) + return REPO_ROOT / "target" / "debug" / "server" + + +@pytest.fixture +def appview_url(starhaven_binary: Path): + """Base URL of a freshly started server, torn down after the test.""" + port = _free_port() + base_url = f"http://127.0.0.1:{port}" + with tempfile.TemporaryDirectory() as data_dir: + process = subprocess.Popen( + [ + str(starhaven_binary), + "--listen", + f"127.0.0.1:{port}", + "--external-base", + base_url, + "--data-dir", + data_dir, + ], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + ) + try: + _wait_until_ready(base_url) + yield base_url + finally: + process.terminate() + try: + process.wait(timeout=5) + except subprocess.TimeoutExpired: + process.kill() + + +@pytest.fixture +def pds_url() -> str: + return DEV_PDS_URL + + +@pytest.fixture +def alice_did(pds_url: str) -> str: + """DID of the dev PDS's seeded `alice.test` account.""" + response = requests.get( + f"{pds_url}/xrpc/com.atproto.identity.resolveHandle", + params={"handle": "alice.test"}, + timeout=5, + ) + response.raise_for_status() + return response.json()["did"] diff --git a/tests/test_mod_listing.py b/tests/test_mod_listing.py new file mode 100644 index 0000000..5a62e99 --- /dev/null +++ b/tests/test_mod_listing.py @@ -0,0 +1,36 @@ +import time + +import requests +from playwright.sync_api import Page, expect + +from conftest import login + + +def test_create_mod_listing_writes_to_pds( + page: Page, appview_url: str, pds_url: str, alice_did: str +): + login(page, appview_url) + + slug = f"test-mod-{int(time.time() * 1000)}" + + page.goto(f"{appview_url}/new") + page.get_by_label("Title").fill("Test Mod") + page.get_by_label("Slug").fill(slug) + page.get_by_label("Description").fill("A mod created by an E2E test.") + page.get_by_role("button", name="Create mod").click() + + expect(page).to_have_url(f"{appview_url}/alice.test/{slug}") + expect(page.get_by_role("heading", name="Test Mod")).to_be_visible() + + # Confirm write reached PDS + response = requests.get( + f"{pds_url}/xrpc/com.atproto.repo.listRecords", + params={"repo": alice_did, "collection": "dev.starhaven.mod.listing"}, + timeout=5, + ) + response.raise_for_status() + records = response.json()["records"] + record = next(r for r in records if r["value"]["slug"] == slug) + assert record["value"]["title"] == "Test Mod" + assert record["value"]["description"] == "A mod created by an E2E test." + assert record["value"]["$type"] == "dev.starhaven.mod.listing"