diff --git a/docker-compose.e2e.yml b/docker-compose.e2e.yml index 69f871f..8135487 100644 --- a/docker-compose.e2e.yml +++ b/docker-compose.e2e.yml @@ -47,10 +47,23 @@ services: plc: condition: service_started + caddy: + image: caddy:2-alpine + volumes: + - ./scripts/e2e-Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + ports: + - "443:443" + networks: + default: + aliases: + - pds.localhost + happyview: build: context: . dockerfile: Dockerfile + entrypoint: ["/bin/sh", "/e2e-entrypoint.sh"] environment: DATABASE_URL: postgres://happyview:happyview@postgres:5432/happyview_test PUBLIC_URL: http://127.0.0.1:3200 @@ -63,8 +76,16 @@ services: JETSTREAM_URL: wss://jetstream1.us-east.bsky.network ports: - "3200:3000" + volumes: + - ./scripts/e2e-entrypoint.sh:/e2e-entrypoint.sh:ro + - caddy_data:/caddy-data:ro depends_on: postgres: condition: service_healthy plc: condition: service_started + caddy: + condition: service_started + +volumes: + caddy_data: diff --git a/scripts/e2e-Caddyfile b/scripts/e2e-Caddyfile new file mode 100644 index 0000000..7e4def9 --- /dev/null +++ b/scripts/e2e-Caddyfile @@ -0,0 +1,4 @@ +pds.localhost { + tls internal + reverse_proxy pds:3000 +} diff --git a/scripts/e2e-config.toml b/scripts/e2e-config.toml index a667785..14a868f 100644 --- a/scripts/e2e-config.toml +++ b/scripts/e2e-config.toml @@ -1,8 +1,9 @@ [server] -hostname = "localhost" +hostname = "pds.localhost" allow_http_proxy = true invite_code_required = false disable_rate_limiting = true +disable_account_verification_gate = true [database] url = "postgres://happyview:happyview@postgres:5432/pds" diff --git a/scripts/e2e-entrypoint.sh b/scripts/e2e-entrypoint.sh new file mode 100644 index 0000000..663927f --- /dev/null +++ b/scripts/e2e-entrypoint.sh @@ -0,0 +1,16 @@ +#!/bin/sh +set -e + +# Wait for Caddy's internal CA certificate to appear in the shared volume, +# then install it so reqwest (native-tls / OpenSSL) trusts TLS connections +# proxied through Caddy (e.g. the PDS OAuth endpoints). +CA_CERT=/caddy-data/caddy/pki/authorities/local/root.crt +if [ -d /caddy-data ]; then + echo "Waiting for Caddy CA certificate..." + while [ ! -f "$CA_CERT" ]; do sleep 0.5; done + cp "$CA_CERT" /usr/local/share/ca-certificates/caddy-local.crt + update-ca-certificates 2>/dev/null + echo "Caddy CA certificate installed." +fi + +exec /entrypoint.sh diff --git a/src/auth/routes.rs b/src/auth/routes.rs index cf9a61f..6b91636 100644 --- a/src/auth/routes.rs +++ b/src/auth/routes.rs @@ -16,6 +16,10 @@ use serde::Deserialize; /// Detected and removed in the callback to clean up stale cookies. const LEGACY_REDIRECT_COOKIE: &str = "happyview_redirect"; +fn is_https(public_url: &str) -> bool { + public_url.starts_with("https://") +} + #[derive(Deserialize)] pub struct LoginQuery { handle: String, @@ -212,6 +216,7 @@ async fn callback( // Check if the user is authorized to access the dashboard. // Allow login when no users exist yet (first user will be bootstrapped as admin). + // Also allow login for the configured attached account DID (setup attach-auth flow). // Otherwise, only allow users already in the users table. let user_count: (i64,) = sqlx::query_as("SELECT COUNT(*) FROM users") .fetch_one(&state.db) @@ -229,13 +234,25 @@ async fn callback( .map_err(|e| AppError::Internal(format!("user lookup failed: {e}")))?; if user_exists.is_none() { - let login_url = state - .config - .base_path - .as_ref() - .map(|bp| format!("{}/login?error=not_authorized", bp)) - .unwrap_or_else(|| "/login?error=not_authorized".into()); - return Ok((jar, Redirect::to(&login_url))); + // Allow login if this DID is the configured attached account (setup flow) + let is_attached_account: Option<(i32,)> = sqlx::query_as(&adapt_sql( + "SELECT 1 FROM service_identity WHERE attached_account_did = ?", + state.db_backend, + )) + .bind(did.as_ref()) + .fetch_optional(&state.db) + .await + .unwrap_or(None); + + if is_attached_account.is_none() { + let login_url = state + .config + .base_path + .as_ref() + .map(|bp| format!("{}/login?error=not_authorized", bp)) + .unwrap_or_else(|| "/login?error=not_authorized".into()); + return Ok((jar, Redirect::to(&login_url))); + } } } @@ -274,18 +291,24 @@ async fn callback( } else { did_str.to_string() }; + let secure = is_https(&state.config.public_url); + let same_site = if secure { + axum_extra::extract::cookie::SameSite::None + } else { + axum_extra::extract::cookie::SameSite::Lax + }; let mut session_cookie = Cookie::new(COOKIE_NAME, cookie_value); session_cookie.set_path("/"); session_cookie.set_http_only(true); - session_cookie.set_same_site(axum_extra::extract::cookie::SameSite::None); - session_cookie.set_secure(true); // Required when SameSite=None + session_cookie.set_same_site(same_site); + session_cookie.set_secure(secure); // Remove the legacy redirect cookie if present (old cookie-based approach) let jar = if jar.get(LEGACY_REDIRECT_COOKIE).is_some() { let mut removal = Cookie::from(LEGACY_REDIRECT_COOKIE); removal.set_path("/"); - removal.set_same_site(axum_extra::extract::cookie::SameSite::None); - removal.set_secure(true); + removal.set_same_site(same_site); + removal.set_secure(secure); jar.add(session_cookie).remove(removal) } else { jar.add(session_cookie) @@ -306,10 +329,16 @@ async fn logout( } } + let secure = is_https(&state.config.public_url); + let same_site = if secure { + axum_extra::extract::cookie::SameSite::None + } else { + axum_extra::extract::cookie::SameSite::Lax + }; let mut removal = Cookie::from(COOKIE_NAME); removal.set_path("/"); - removal.set_same_site(axum_extra::extract::cookie::SameSite::None); - removal.set_secure(true); + removal.set_same_site(same_site); + removal.set_secure(secure); let jar = jar.remove(removal); Ok(jar) } diff --git a/src/auth/service_auth.rs b/src/auth/service_auth.rs index 48cb738..fa9f0d9 100644 --- a/src/auth/service_auth.rs +++ b/src/auth/service_auth.rs @@ -349,4 +349,90 @@ mod tests { assert!(decode_jwt_payload("onlyonepart").is_err()); assert!(decode_jwt_payload("two.parts").is_err()); } + + #[test] + fn decode_jwt_payload_rejects_not_three_parts() { + assert!(decode_jwt_payload("notenoughparts").is_err()); + assert!(decode_jwt_payload("two.parts").is_err()); + assert!(decode_jwt_payload("a.b.c.d").is_err()); + } + + #[test] + fn decode_jwt_payload_rejects_invalid_base64() { + let jwt = "validheader.!!!invalid-base64!!!.sig"; + let result = decode_jwt_payload(jwt); + assert!(result.is_err()); + } + + #[test] + fn verify_es256_rejects_invalid_key_bytes() { + assert!(!verify_es256(b"test message", &[0u8; 64], &[0xFF; 5])); + } + + #[test] + fn verify_es256k_rejects_invalid_key_bytes() { + assert!(!verify_es256k(b"test message", &[0u8; 64], &[0xFF; 5])); + } + + #[test] + fn decode_multibase_key_rejects_invalid_multibase() { + let result = decode_multibase_key("not-a-valid-multibase-string!!!", "Multikey"); + assert!(result.is_err()); + let msg = format!("{}", result.unwrap_err()); + assert!( + msg.contains("multibase"), + "error should mention multibase: {msg}" + ); + } + + #[test] + fn decode_multibase_key_secp256r1_returns_raw_bytes() { + let raw_bytes = vec![0x04, 0xAA, 0xBB, 0xCC, 0xDD]; + let encoded = multibase::encode(multibase::Base::Base58Btc, &raw_bytes); + let result = decode_multibase_key(&encoded, "EcdsaSecp256r1VerificationKey2019").unwrap(); + assert_eq!(result, raw_bytes); + } + + #[test] + fn decode_multibase_key_secp256k1_returns_raw_bytes() { + let raw_bytes = vec![0x02, 0x11, 0x22, 0x33]; + let encoded = multibase::encode(multibase::Base::Base58Btc, &raw_bytes); + let result = decode_multibase_key(&encoded, "EcdsaSecp256k1VerificationKey2019").unwrap(); + assert_eq!(result, raw_bytes); + } + + #[test] + fn decode_multibase_key_unknown_type_rejected() { + let raw_bytes = vec![0x80, 0x24, 0x01, 0x02, 0x03]; + let encoded = multibase::encode(multibase::Base::Base58Btc, &raw_bytes); + let result = decode_multibase_key(&encoded, "UnknownKeyType2099"); + assert!(result.is_err()); + let msg = format!("{}", result.unwrap_err()); + assert!( + msg.contains("unsupported"), + "error should mention unsupported: {msg}" + ); + } + + #[test] + fn decode_multibase_key_multikey_too_short() { + let short_bytes = vec![0x80]; + let encoded = multibase::encode(multibase::Base::Base58Btc, &short_bytes); + let result = decode_multibase_key(&encoded, "Multikey"); + assert!(result.is_err()); + let msg = format!("{}", result.unwrap_err()); + assert!( + msg.contains("too short"), + "error should mention too short: {msg}" + ); + } + + #[test] + fn decode_multibase_key_multikey_strips_prefix() { + let mut bytes = vec![0x80, 0x24]; + bytes.extend_from_slice(&[0xAA, 0xBB, 0xCC]); + let encoded = multibase::encode(multibase::Base::Base58Btc, &bytes); + let result = decode_multibase_key(&encoded, "Multikey").unwrap(); + assert_eq!(result, vec![0xAA, 0xBB, 0xCC]); + } } diff --git a/src/lua_analysis.rs b/src/lua_analysis.rs index f7ab829..3d0c663 100644 --- a/src/lua_analysis.rs +++ b/src/lua_analysis.rs @@ -10,12 +10,17 @@ static XRPC_CALL_RE: LazyLock = LazyLock::new(|| { /// Used to detect lines that are fully commented out before any code. static LUA_COMMENT_RE: LazyLock = LazyLock::new(|| Regex::new(r"^\s*--").unwrap()); +/// Strips Lua block comments (`--[[ ... ]]`) from source, including multi-line ones. +static BLOCK_COMMENT_RE: LazyLock = + LazyLock::new(|| Regex::new(r"--\[\[[\s\S]*?\]\]").unwrap()); + pub fn extract_outbound_xrpcs(source: &str) -> Vec { let mut seen = std::collections::HashSet::new(); let mut result = Vec::new(); - for line in source.lines() { - // Skip lines whose non-whitespace content starts with a Lua comment (`--`). + let stripped = BLOCK_COMMENT_RE.replace_all(source, ""); + + for line in stripped.lines() { if LUA_COMMENT_RE.is_match(line) { continue; } @@ -122,4 +127,114 @@ mod tests { vec!["games.birb.chess.getGame", "games.birb.chess.listGames",] ); } + + #[test] + fn handles_multiline_scripts() { + let source = r#" +function handle(input, params) + local game = xrpc.query("games.birb.chess.getGame", + { uri = params.uri }) + local result = xrpc.procedure("games.birb.chess.makeMove", + { game = game.uri, + move = params.move }) + return result +end + "#; + let result = extract_outbound_xrpcs(source); + assert_eq!( + result, + vec!["games.birb.chess.getGame", "games.birb.chess.makeMove",] + ); + } + + #[test] + fn handles_mixed_comments_and_code() { + let source = r#" +function handle(input, params) + -- This is a comment about the next call + local game = xrpc.query("games.birb.chess.getGame", { uri = params.uri }) + -- local old = xrpc.query("games.birb.chess.oldEndpoint", {}) + -- xrpc.procedure("games.birb.chess.deprecatedMove", {}) + xrpc.procedure("games.birb.chess.makeMove", { game = game.uri }) + return game +end + "#; + let result = extract_outbound_xrpcs(source); + assert_eq!( + result, + vec!["games.birb.chess.getGame", "games.birb.chess.makeMove",] + ); + } + + #[test] + fn ignores_block_comment_single_line() { + let source = r#" + --[[ local result = xrpc.query("games.birb.chess.getGame", { uri = params.uri }) ]] + return {} + "#; + let result = extract_outbound_xrpcs(source); + assert!(result.is_empty()); + } + + #[test] + fn ignores_block_comment_multiline() { + let source = r#" + --[[ + local result = xrpc.query("games.birb.chess.getGame", { uri = params.uri }) + xrpc.procedure("games.birb.chess.makeMove", {}) + ]] + local active = xrpc.query("games.birb.chess.listGames", {}) + "#; + let result = extract_outbound_xrpcs(source); + assert_eq!(result, vec!["games.birb.chess.listGames"]); + } + + #[test] + fn dynamic_method_names_not_detected() { + let source = r#" + local method = "games.birb.chess.getGame" + local result = xrpc.query(method, {}) + "#; + let result = extract_outbound_xrpcs(source); + assert!( + result.is_empty(), + "dynamically constructed method names should not be detected" + ); + } + + #[test] + fn extracts_from_complex_lua() { + let source = r#" +function handle(input, params) + local results = {} + + if params.include_profile then + local profile = xrpc.query("app.bsky.actor.getProfile", { actor = params.did }) + table.insert(results, profile) + end + + for i = 1, params.count do + local feed = xrpc.query("app.bsky.feed.getAuthorFeed", { actor = params.did, limit = 10 }) + for _, post in ipairs(feed.feed) do + table.insert(results, post) + end + end + + if params.should_notify then + xrpc.procedure("games.birb.chess.sendNotification", { target = params.did }) + end + + return { items = results } +end + "#; + let result = extract_outbound_xrpcs(source); + assert_eq!( + result, + vec![ + "app.bsky.actor.getProfile", + "app.bsky.feed.getAuthorFeed", + "games.birb.chess.sendNotification", + ] + ); + } } diff --git a/src/plc.rs b/src/plc.rs index cc23535..7000272 100644 --- a/src/plc.rs +++ b/src/plc.rs @@ -308,4 +308,54 @@ mod tests { let did_key = private_key_to_did_key(&key_bytes).unwrap(); assert!(did_key.starts_with("did:key:z")); } + + #[test] + fn decrypt_key_invalid_base64() { + let encryption_key = [0x42u8; 32]; + let result = decrypt_key("not valid base64!!!", &encryption_key); + assert!(result.is_err()); + let msg = format!("{}", result.unwrap_err()); + assert!( + msg.contains("decode"), + "error should mention decoding: {msg}" + ); + } + + #[test] + fn decrypt_key_wrong_encryption_key() { + let correct_key = [0x42u8; 32]; + let wrong_key = [0x99u8; 32]; + + let plaintext = [0xAAu8; 32]; + let encrypted = crate::plugin::encryption::encrypt(&correct_key, &plaintext).unwrap(); + let enc_b64 = base64::engine::general_purpose::STANDARD.encode(&encrypted); + + let result = decrypt_key(&enc_b64, &wrong_key); + assert!(result.is_err()); + let msg = format!("{}", result.unwrap_err()); + assert!( + msg.contains("decrypt"), + "error should mention decryption: {msg}" + ); + } + + #[test] + fn private_key_to_did_key_rejects_invalid_bytes() { + let result = private_key_to_did_key(&[0x00; 32]); + assert!(result.is_err()); + let msg = format!("{}", result.unwrap_err()); + assert!( + msg.contains("invalid signing key"), + "error should mention invalid: {msg}" + ); + } + + #[test] + fn extract_prev_cid_missing_field() { + let op = serde_json::json!({"type": "plc_operation"}); + let result = extract_prev_cid(&op); + assert!(result.is_err()); + let msg = format!("{}", result.unwrap_err()); + assert!(msg.contains("CID"), "error should mention CID: {msg}"); + } } diff --git a/src/proxy_config.rs b/src/proxy_config.rs index 32c296a..0b8794a 100644 --- a/src/proxy_config.rs +++ b/src/proxy_config.rs @@ -158,6 +158,25 @@ mod tests { assert!(config.allows("com.other.feed.getHot")); } + #[test] + fn wildcard_does_not_match_prefix_without_dot() { + let config = ProxyConfig { + mode: ProxyMode::Allowlist, + nsids: vec!["com.example.*".into()], + }; + assert!( + !config.allows("com.example"), + "bare prefix should not match wildcard" + ); + } + + #[test] + fn validate_rejects_invalid_characters() { + assert!(validate_nsid_pattern("com.ex@mple.foo").is_err()); + assert!(validate_nsid_pattern("com.ex mple.foo").is_err()); + assert!(validate_nsid_pattern("com.ex_mple.foo").is_err()); + } + #[test] fn validate_valid_nsids() { assert!(validate_nsid_pattern("com.example.feed.getHot").is_ok()); diff --git a/src/setup.rs b/src/setup.rs index fb45bce..6d077d6 100644 --- a/src/setup.rs +++ b/src/setup.rs @@ -488,11 +488,17 @@ async fn attach_auth_confirm( return Err(AppError::Auth("original_did is not a known user".into())); } + let secure = state.config.public_url.starts_with("https://"); + let same_site = if secure { + axum_extra::extract::cookie::SameSite::None + } else { + axum_extra::extract::cookie::SameSite::Lax + }; let mut session_cookie = Cookie::new(COOKIE_NAME, original_did); session_cookie.set_path("/"); session_cookie.set_http_only(true); - session_cookie.set_same_site(axum_extra::extract::cookie::SameSite::None); - session_cookie.set_secure(true); + session_cookie.set_same_site(same_site); + session_cookie.set_secure(secure); let jar = jar.add(session_cookie); diff --git a/tests/e2e_admin_service_entries.rs b/tests/e2e_admin_service_entries.rs index eed4ee4..3e250c4 100644 --- a/tests/e2e_admin_service_entries.rs +++ b/tests/e2e_admin_service_entries.rs @@ -8,6 +8,7 @@ use serial_test::serial; use tower::ServiceExt; use common::app::TestApp; +use common::plc; async fn json_body(resp: axum::response::Response) -> Value { let body = resp.into_body().collect().await.unwrap().to_bytes(); @@ -358,3 +359,566 @@ async fn lexicon_services_reverse_lookup() { assert_eq!(list.len(), 1); assert_eq!(list[0]["id"].as_i64().unwrap(), id_all); } + +// --------------------------------------------------------------------------- +// Update entry with empty body — short-circuit path +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn update_entry_with_empty_body() { + common::require_db!(); + let app = TestApp::new().await; + let cookie = app.admin_cookie(); + + let entry_id = app + .create_service_entry("#empty", "EmptyUpdate", "all") + .await; + + // Send an update with no fields — should succeed (no-op update) + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri(format!("/admin/service-entries/{}", entry_id)) + .header(cookie.0.clone(), cookie.1.clone()) + .header("content-type", "application/json") + .body(Body::from(serde_json::to_vec(&json!({})).unwrap())) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::NO_CONTENT, + "empty update body should succeed" + ); + + // Verify entry is unchanged + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/admin/service-entries") + .header(cookie.0, cookie.1) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let list = json_body(resp).await; + let entries = list.as_array().unwrap(); + let entry = entries + .iter() + .find(|e| e["id"].as_i64() == Some(entry_id)) + .unwrap(); + assert_eq!(entry["service_type"], "EmptyUpdate"); + assert_eq!(entry["access_mode"], "all"); +} + +// --------------------------------------------------------------------------- +// Non-admin permission checks +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn unauthenticated_list_entries_rejected() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/admin/service-entries") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert!( + resp.status().is_client_error(), + "unauthenticated request to admin endpoint should be rejected, got {}", + resp.status() + ); +} + +#[tokio::test] +#[serial] +async fn unauthenticated_create_entry_rejected() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/admin/service-entries") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "fragment_id": "#noauth", + "service_type": "NoAuth" + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert!( + resp.status().is_client_error(), + "unauthenticated POST to admin endpoint should be rejected, got {}", + resp.status() + ); +} + +#[tokio::test] +#[serial] +async fn unauthenticated_delete_entry_rejected() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/admin/service-entries/1") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert!( + resp.status().is_client_error(), + "unauthenticated DELETE to admin endpoint should be rejected, got {}", + resp.status() + ); +} + +#[tokio::test] +#[serial] +async fn unauthenticated_sync_plc_rejected() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/admin/service-entries/sync-plc") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert!( + resp.status().is_client_error(), + "unauthenticated POST to sync-plc should be rejected, got {}", + resp.status() + ); +} + +// --------------------------------------------------------------------------- +// Update nonexistent entry returns 404 +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn update_nonexistent_entry_returns_404() { + common::require_db!(); + let app = TestApp::new().await; + let cookie = app.admin_cookie(); + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri("/admin/service-entries/99999") + .header(cookie.0, cookie.1) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "access_mode": "specific" + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::NOT_FOUND, + "updating a nonexistent entry should return 404" + ); +} + +// --------------------------------------------------------------------------- +// XRPC idempotency — adding the same NSID twice +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn add_entry_xrpcs_idempotent() { + common::require_db!(); + let app = TestApp::new().await; + let cookie = app.admin_cookie(); + + let entry_id = app + .create_service_entry("#chess", "ChessAppView", "specific") + .await; + + let add_body = serde_json::to_vec(&json!({ + "lexicon_ids": ["games.example.listGames"] + })) + .unwrap(); + + // Add once + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/admin/service-entries/{}/xrpcs", entry_id)) + .header(cookie.0.clone(), cookie.1.clone()) + .header("content-type", "application/json") + .body(Body::from(add_body.clone())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + // Add the same NSID again — should succeed (ON CONFLICT DO NOTHING) + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/admin/service-entries/{}/xrpcs", entry_id)) + .header(cookie.0.clone(), cookie.1.clone()) + .header("content-type", "application/json") + .body(Body::from(add_body)) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + resp.status(), + StatusCode::NO_CONTENT, + "adding the same xrpc twice should succeed idempotently" + ); + + // Verify only one entry exists + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri(format!("/admin/service-entries/{}/xrpcs", entry_id)) + .header(cookie.0, cookie.1) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + let xrpcs = json_body(resp).await; + assert_eq!( + xrpcs.as_array().unwrap().len(), + 1, + "should have exactly one entry after duplicate add" + ); +} + +// --------------------------------------------------------------------------- +// Unauthenticated access to remaining endpoints +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn unauthenticated_update_entry_rejected() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri("/admin/service-entries/1") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"access_mode": "all"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert!( + resp.status().is_client_error(), + "unauthenticated PUT should be rejected, got {}", + resp.status() + ); +} + +#[tokio::test] +#[serial] +async fn unauthenticated_list_xrpcs_rejected() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/admin/service-entries/1/xrpcs") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert!( + resp.status().is_client_error(), + "unauthenticated GET xrpcs should be rejected, got {}", + resp.status() + ); +} + +#[tokio::test] +#[serial] +async fn unauthenticated_add_xrpcs_rejected() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/admin/service-entries/1/xrpcs") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"lexicon_ids": ["test.foo.bar"]})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert!( + resp.status().is_client_error(), + "unauthenticated POST xrpcs should be rejected, got {}", + resp.status() + ); +} + +#[tokio::test] +#[serial] +async fn unauthenticated_remove_xrpcs_rejected() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("DELETE") + .uri("/admin/service-entries/1/xrpcs") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"lexicon_ids": ["test.foo.bar"]})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert!( + resp.status().is_client_error(), + "unauthenticated DELETE xrpcs should be rejected, got {}", + resp.status() + ); +} + +#[tokio::test] +#[serial] +async fn unauthenticated_lexicon_services_rejected() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/admin/lexicons/test.foo.bar/services") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert!( + resp.status().is_client_error(), + "unauthenticated lexicon services lookup should be rejected, got {}", + resp.status() + ); +} + +// --------------------------------------------------------------------------- +// PLC sync — did_plc mode +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn sync_plc_updates_did_document() { + common::require_db!(); + let mut app = TestApp::new().await; + let plc_store = plc::setup_mock_plc(&app.mock_server).await; + let did = app.setup_did_plc().await; + + // Generate and store a rotation key (setup_did_plc only stores a signing key) + let encryption_key = [0x42u8; 32]; + + let mut rotation_key_bytes = [0u8; 32]; + rand::RngCore::fill_bytes(&mut rand::rng(), &mut rotation_key_bytes); + let _rotation_key = + p256::ecdsa::SigningKey::from_bytes((&rotation_key_bytes[..]).into()).unwrap(); + let encrypted = happyview::plugin::encryption::encrypt(&encryption_key, &rotation_key_bytes) + .expect("encryption failed"); + let rotation_key_enc = + base64::Engine::encode(&base64::engine::general_purpose::STANDARD, &encrypted); + + let sql = happyview::db::adapt_sql( + "UPDATE service_identity SET rotation_key_enc = ? WHERE id = 1", + app.state.db_backend, + ); + sqlx::query(&sql) + .bind(&rotation_key_enc) + .execute(&app.state.db) + .await + .expect("failed to store rotation key"); + + // Build a genesis-like PLC document for the mock + let rotation_did_key = happyview::plc::private_key_to_did_key(&rotation_key_bytes).unwrap(); + + // Compute the signing key's did:key from the stored identity + let identity = happyview::service_identity::get_identity(&app.state.db, app.state.db_backend) + .await + .unwrap() + .unwrap(); + let signing_key_bytes = + happyview::plc::decrypt_key(identity.signing_key_enc.as_ref().unwrap(), &encryption_key) + .unwrap(); + let signing_did_key = happyview::plc::private_key_to_did_key(&signing_key_bytes).unwrap(); + + let genesis_doc = json!({ + "type": "plc_operation", + "rotationKeys": [&rotation_did_key], + "verificationMethods": { + "atproto": &signing_did_key, + }, + "alsoKnownAs": [], + "services": {}, + "prev": null, + "cid": "bafyreiaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + }); + + plc_store.write().await.insert(did.clone(), genesis_doc); + + // Create a service entry + app.create_service_entry("#chess", "ChessAppView", "all") + .await; + + // POST sync-plc + let cookie = app.admin_cookie(); + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/admin/service-entries/sync-plc") + .header(cookie.0, cookie.1) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::NO_CONTENT, + "sync-plc should return 204" + ); + + // Verify the PLC mock received the updated document + let store = plc_store.read().await; + let updated = store.get(&did).expect("PLC store should have the DID"); + let services = updated["services"] + .as_object() + .expect("services should exist"); + assert!( + services.contains_key("chess"), + "services should contain the chess entry" + ); + assert_eq!(updated["services"]["chess"]["type"], "ChessAppView"); +} + +#[tokio::test] +#[serial] +async fn sync_plc_rejects_non_plc_mode() { + common::require_db!(); + let mut app = TestApp::new().await; + app.setup_did_web().await; + + let cookie = app.admin_cookie(); + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/admin/service-entries/sync-plc") + .header(cookie.0, cookie.1) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "sync-plc should reject non-plc mode" + ); +} diff --git a/tests/e2e_proxy_config.rs b/tests/e2e_proxy_config.rs index 8fe8d4d..d8eb6ca 100644 --- a/tests/e2e_proxy_config.rs +++ b/tests/e2e_proxy_config.rs @@ -176,6 +176,41 @@ async fn invalid_nsid_rejected() { assert_eq!(resp.status(), StatusCode::BAD_REQUEST); } +#[tokio::test] +#[serial] +async fn put_and_get_blocklist() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot(admin_put( + "/admin/settings/xrpc-proxy", + app.admin_cookie(), + &json!({ + "mode": "blocklist", + "nsids": ["com.blocked.feed.*"] + }), + )) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + let resp = app + .router + .clone() + .oneshot(admin_get("/admin/settings/xrpc-proxy", app.admin_cookie())) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let json = json_body(resp).await; + assert_eq!(json["mode"], "blocklist"); + assert_eq!(json["nsids"], json!(["com.blocked.feed.*"])); +} + #[tokio::test] #[serial] async fn requires_auth() { diff --git a/tests/e2e_service_identity.rs b/tests/e2e_service_identity.rs index 25793c5..05a94e5 100644 --- a/tests/e2e_service_identity.rs +++ b/tests/e2e_service_identity.rs @@ -1552,6 +1552,96 @@ async fn service_auth_works_with_did_plc_identity() { ); } +// --------------------------------------------------------------------------- +// Service auth — ES256K (secp256k1) +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn service_auth_es256k_query_allowed() { + common::require_db!(); + let mut app = TestApp::new().await; + let plc_store = plc::setup_mock_plc(&app.mock_server).await; + let did = app.setup_did_web().await; + + seed_query_lexicon(&app).await; + + app.create_service_entry("#chess", "ChessAppView", "all") + .await; + + // Generate a secp256k1 key pair + use k256::ecdsa::{SigningKey as K256SigningKey, signature::Signer as K256Signer}; + use rand::RngCore; + + let mut key_bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut key_bytes); + let k256_signing_key = K256SigningKey::from_bytes((&key_bytes[..]).into()).unwrap(); + let k256_verifying_key = k256_signing_key.verifying_key(); + let compressed = k256_verifying_key.to_encoded_point(true); + let pub_bytes = compressed.as_bytes(); + + // Build a DID document with secp256k1 key using EcdsaSecp256k1VerificationKey2019 + // This type uses raw SEC1 key bytes (no multicodec prefix) + let multibase_key = multibase::encode(multibase::Base::Base58Btc, pub_bytes); + + let issuer_did = "did:plc:es256kcaller"; + let did_doc = json!({ + "@context": ["https://www.w3.org/ns/did/v1", "https://w3id.org/security/multikey/v1"], + "id": issuer_did, + "verificationMethod": [{ + "id": format!("{issuer_did}#atproto"), + "type": "EcdsaSecp256k1VerificationKey2019", + "controller": issuer_did, + "publicKeyMultibase": multibase_key + }], + "service": [] + }); + + plc_store + .write() + .await + .insert(issuer_did.to_string(), did_doc); + + // Sign a JWT with ES256K + use base64::Engine; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + + let header = json!({"alg": "ES256K"}); + let payload = json!({ + "iss": issuer_did, + "aud": format!("{did}#chess"), + "exp": chrono::Utc::now().timestamp() as u64 + 60, + }); + + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()); + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap()); + let message = format!("{}.{}", header_b64, payload_b64); + + let signature: k256::ecdsa::Signature = k256_signing_key.sign(message.as_bytes()); + let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); + + let auth = format!("Bearer {}.{}.{}", header_b64, payload_b64, sig_b64); + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/xrpc/games.gamesgamesgamesgames.listGames") + .header("authorization", &auth) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::OK, + "ES256K service auth should be accepted" + ); +} + // --------------------------------------------------------------------------- // Anonymous POST to procedure is rejected // --------------------------------------------------------------------------- @@ -1588,3 +1678,515 @@ async fn anonymous_procedure_rejected() { "anonymous POST to procedure should be rejected" ); } + +// --------------------------------------------------------------------------- +// Proxy config blocking — XRPC method blocked by proxy policy +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn proxy_config_disabled_rejects_unknown_method() { + common::require_db!(); + let mut app = TestApp::new().await; + app.setup_did_web().await; + + // Set proxy config to disabled + app.state + .proxy_config + .store(std::sync::Arc::new(happyview::proxy_config::ProxyConfig { + mode: happyview::proxy_config::ProxyMode::Disabled, + nsids: vec![], + })); + + app.rebuild_router(); + + // Query an unknown method (not in lexicon registry) — should be blocked + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/xrpc/com.unknown.method") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::FORBIDDEN, + "disabled proxy config should reject unknown methods" + ); +} + +#[tokio::test] +#[serial] +async fn proxy_config_allowlist_rejects_unlisted_method() { + common::require_db!(); + let mut app = TestApp::new().await; + app.setup_did_web().await; + + // Set proxy config to allowlist with a specific pattern + app.state + .proxy_config + .store(std::sync::Arc::new(happyview::proxy_config::ProxyConfig { + mode: happyview::proxy_config::ProxyMode::Allowlist, + nsids: vec!["com.allowed.*".to_string()], + })); + + app.rebuild_router(); + + // Query an unlisted method — should be blocked + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/xrpc/com.blocked.method") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::FORBIDDEN, + "allowlist proxy config should reject unlisted methods" + ); +} + +// --------------------------------------------------------------------------- +// Non-scripted procedure via service auth — falls through to OAuth path +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn service_auth_non_scripted_procedure_fails_gracefully() { + common::require_db!(); + let mut app = TestApp::new().await; + let plc_store = plc::setup_mock_plc(&app.mock_server).await; + let did = app.setup_did_web().await; + + // Seed a procedure lexicon but do NOT seed a script for it + seed_procedure_lexicon(&app).await; + + let entry_id = app + .create_service_entry("#chess", "ChessAppView", "specific") + .await; + app.add_entry_xrpcs(entry_id, &["games.gamesgamesgamesgames.createGame"]) + .await; + + let auth = app + .service_auth_jwt(&plc_store, "did:plc:noscript", &did, "#chess") + .await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/xrpc/games.gamesgamesgamesgames.createGame") + .header("authorization", &auth) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"title": "test"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + // Without a script, service auth goes to the OAuth session path which will fail + // because there's no stored session for the service auth caller. This should + // return a server error, not panic. + assert!( + resp.status().is_client_error() || resp.status().is_server_error(), + "non-scripted procedure via service auth should fail gracefully, got {}", + resp.status() + ); +} + +// --------------------------------------------------------------------------- +// JWT unsupported algorithm rejected +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn unsupported_jwt_algorithm_rejected() { + common::require_db!(); + let mut app = TestApp::new().await; + let plc_store = plc::setup_mock_plc(&app.mock_server).await; + let did = app.setup_did_web().await; + + seed_procedure_lexicon(&app).await; + seed_procedure_script(&app, "function handle(input, params)\nreturn { uri = 'at://test/games.gamesgamesgamesgames.game/1' }\nend").await; + + app.create_service_entry("#chess", "ChessAppView", "all") + .await; + + // JWT with RS256 algorithm (unsupported) + let auth = app + .custom_service_auth_jwt( + &plc_store, + "did:plc:rs256test", + json!({"alg": "RS256"}), + json!({ + "iss": "did:plc:rs256test", + "aud": format!("{}#chess", did), + "exp": chrono::Utc::now().timestamp() as u64 + 60, + }), + ) + .await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/xrpc/games.gamesgamesgamesgames.createGame") + .header("authorization", &auth) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"title": "test"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "unsupported JWT algorithm should be rejected" + ); +} + +// --------------------------------------------------------------------------- +// Unauthenticated access to admin service identity endpoints +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn unauthenticated_get_service_identity_rejected() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/admin/service-identity") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert!( + resp.status().is_client_error(), + "unauthenticated GET /admin/service-identity should be rejected, got {}", + resp.status() + ); +} + +#[tokio::test] +#[serial] +async fn unauthenticated_put_service_identity_rejected() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("PUT") + .uri("/admin/service-identity") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"mode": "not_exposed"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert!( + resp.status().is_client_error(), + "unauthenticated PUT /admin/service-identity should be rejected, got {}", + resp.status() + ); +} + +// --------------------------------------------------------------------------- +// Lexicon type mismatch — GET to procedure, POST to query +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn get_to_procedure_endpoint_rejected() { + common::require_db!(); + let mut app = TestApp::new().await; + app.setup_did_web().await; + + seed_procedure_lexicon(&app).await; + seed_procedure_script(&app, "function handle(input, params)\nreturn { uri = 'at://test/games.gamesgamesgamesgames.game/1' }\nend").await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/xrpc/games.gamesgamesgamesgames.createGame") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "GET to a procedure endpoint should return 400" + ); +} + +#[tokio::test] +#[serial] +async fn post_to_query_endpoint_rejected() { + common::require_db!(); + let mut app = TestApp::new().await; + let plc_store = plc::setup_mock_plc(&app.mock_server).await; + let did = app.setup_did_web().await; + + seed_query_lexicon(&app).await; + app.create_service_entry("#chess", "ChessAppView", "all") + .await; + + let auth = app + .service_auth_jwt(&plc_store, "did:plc:postquery", &did, "#chess") + .await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/xrpc/games.gamesgamesgamesgames.listGames") + .header("authorization", &auth) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"test": true})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "POST to a query endpoint should return 400" + ); +} + +// --------------------------------------------------------------------------- +// DID doc missing #atproto verification method +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn did_doc_missing_atproto_vm_rejected() { + common::require_db!(); + let mut app = TestApp::new().await; + let plc_store = plc::setup_mock_plc(&app.mock_server).await; + let did = app.setup_did_web().await; + + seed_query_lexicon(&app).await; + app.create_service_entry("#chess", "ChessAppView", "all") + .await; + + use base64::Engine; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use p256::ecdsa::{SigningKey, signature::Signer}; + use rand::RngCore; + + let mut key_bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut key_bytes); + let signing_key = SigningKey::from_bytes((&key_bytes[..]).into()).unwrap(); + + let issuer_did = "did:plc:noatprotovm"; + let did_doc = json!({ + "@context": ["https://www.w3.org/ns/did/v1"], + "id": issuer_did, + "verificationMethod": [{ + "id": format!("{issuer_did}#wrongId"), + "type": "Multikey", + "controller": issuer_did, + "publicKeyMultibase": "zNotARealKey" + }], + "service": [] + }); + plc_store + .write() + .await + .insert(issuer_did.to_string(), did_doc); + + let header = json!({"alg": "ES256"}); + let payload = json!({ + "iss": issuer_did, + "aud": format!("{}#chess", did), + "exp": chrono::Utc::now().timestamp() as u64 + 60, + }); + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()); + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap()); + let message = format!("{}.{}", header_b64, payload_b64); + let signature: p256::ecdsa::Signature = signing_key.sign(message.as_bytes()); + let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); + let auth = format!("Bearer {}.{}.{}", header_b64, payload_b64, sig_b64); + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/xrpc/games.gamesgamesgamesgames.listGames") + .header("authorization", &auth) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "DID doc without #atproto VM should reject service auth" + ); +} + +#[tokio::test] +#[serial] +async fn did_doc_missing_public_key_multibase_rejected() { + common::require_db!(); + let mut app = TestApp::new().await; + let plc_store = plc::setup_mock_plc(&app.mock_server).await; + let did = app.setup_did_web().await; + + seed_query_lexicon(&app).await; + app.create_service_entry("#chess", "ChessAppView", "all") + .await; + + use base64::Engine; + use base64::engine::general_purpose::URL_SAFE_NO_PAD; + use p256::ecdsa::{SigningKey, signature::Signer}; + use rand::RngCore; + + let mut key_bytes = [0u8; 32]; + rand::rng().fill_bytes(&mut key_bytes); + let signing_key = SigningKey::from_bytes((&key_bytes[..]).into()).unwrap(); + + let issuer_did = "did:plc:nokeymultibase"; + let did_doc = json!({ + "@context": ["https://www.w3.org/ns/did/v1"], + "id": issuer_did, + "verificationMethod": [{ + "id": format!("{issuer_did}#atproto"), + "type": "Multikey", + "controller": issuer_did + }], + "service": [] + }); + plc_store + .write() + .await + .insert(issuer_did.to_string(), did_doc); + + let header = json!({"alg": "ES256"}); + let payload = json!({ + "iss": issuer_did, + "aud": format!("{}#chess", did), + "exp": chrono::Utc::now().timestamp() as u64 + 60, + }); + let header_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&header).unwrap()); + let payload_b64 = URL_SAFE_NO_PAD.encode(serde_json::to_vec(&payload).unwrap()); + let message = format!("{}.{}", header_b64, payload_b64); + let signature: p256::ecdsa::Signature = signing_key.sign(message.as_bytes()); + let sig_b64 = URL_SAFE_NO_PAD.encode(signature.to_bytes()); + let auth = format!("Bearer {}.{}.{}", header_b64, payload_b64, sig_b64); + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/xrpc/games.gamesgamesgamesgames.listGames") + .header("authorization", &auth) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "DID doc without publicKeyMultibase should reject service auth" + ); +} + +// --------------------------------------------------------------------------- +// JWT allowed typ (e.g., "JWT") is accepted +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn jwt_with_allowed_typ_accepted() { + common::require_db!(); + let mut app = TestApp::new().await; + let plc_store = plc::setup_mock_plc(&app.mock_server).await; + let did = app.setup_did_web().await; + + seed_query_lexicon(&app).await; + + app.create_service_entry("#chess", "ChessAppView", "all") + .await; + + let auth = app + .custom_service_auth_jwt( + &plc_store, + "did:plc:goodtyp", + json!({"alg": "ES256", "typ": "JWT"}), + json!({ + "iss": "did:plc:goodtyp", + "aud": format!("{}#chess", did), + "exp": chrono::Utc::now().timestamp() as u64 + 60, + }), + ) + .await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/xrpc/games.gamesgamesgamesgames.listGames") + .header("authorization", &auth) + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::OK, + "JWT with typ=JWT should be accepted" + ); +} diff --git a/tests/e2e_setup.rs b/tests/e2e_setup.rs new file mode 100644 index 0000000..ce412ff --- /dev/null +++ b/tests/e2e_setup.rs @@ -0,0 +1,800 @@ +mod common; + +use axum::body::Body; +use axum::http::{Request, StatusCode}; +use http_body_util::BodyExt; +use serde_json::{Value, json}; +use serial_test::serial; +use tower::ServiceExt; + +use common::app::TestApp; +use common::plc; + +async fn json_body(resp: axum::response::Response) -> Value { + let body = resp.into_body().collect().await.unwrap().to_bytes(); + serde_json::from_slice(&body).unwrap() +} + +// --------------------------------------------------------------------------- +// Setup status defaults +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn setup_status_returns_defaults_when_no_identity() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/api/setup/status") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + assert_eq!(body["setup_complete"], false); + assert!(body["identity_mode"].is_null()); +} + +// --------------------------------------------------------------------------- +// Setup identity sets mode +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn setup_identity_sets_mode() { + common::require_db!(); + let mut app = TestApp::new().await; + app.state.config.token_encryption_key = Some([0x42u8; 32]); + app.rebuild_router(); + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/setup/identity") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"mode": "did_web"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/api/setup/status") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + assert_eq!(body["identity_mode"], "did_web"); + assert_eq!(body["identity_configured"], true); +} + +// --------------------------------------------------------------------------- +// Setup identity rejects when complete +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn setup_identity_rejects_when_complete() { + common::require_db!(); + let mut app = TestApp::new().await; + app.setup_did_web().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/setup/identity") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"mode": "did_plc"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::FORBIDDEN, + "setup identity should reject when setup is complete" + ); +} + +// --------------------------------------------------------------------------- +// Setup complete marks done +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn setup_complete_marks_done() { + common::require_db!(); + let app = TestApp::new().await; + + // Set identity to not_exposed (no encryption key needed) + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/setup/identity") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"mode": "not_exposed"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + // Mark complete + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/setup/complete") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + // Verify status + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/api/setup/status") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + assert_eq!(body["setup_complete"], true); +} + +// --------------------------------------------------------------------------- +// Rotation key export +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn rotation_key_export() { + common::require_db!(); + let mut app = TestApp::new().await; + let encryption_key = [0x42u8; 32]; + app.state.config.token_encryption_key = Some(encryption_key); + app.rebuild_router(); + + // Set identity to did_plc via the endpoint (which generates both keys) + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/setup/identity") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"mode": "did_plc"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + // Do NOT mark setup complete -- rotation key export requires setup incomplete + + // GET rotation key + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/api/setup/rotation-key") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::OK, + "rotation key export should succeed for did_plc" + ); + + let body_bytes = resp.into_body().collect().await.unwrap().to_bytes(); + assert!( + !body_bytes.is_empty(), + "rotation key response should have binary content" + ); + // The decrypted key should be 32 bytes (P-256 private key) + assert_eq!(body_bytes.len(), 32, "rotation key should be 32 bytes"); +} + +// --------------------------------------------------------------------------- +// Rotation key export rejects non-PLC +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn rotation_key_export_rejects_non_plc() { + common::require_db!(); + let mut app = TestApp::new().await; + app.state.config.token_encryption_key = Some([0x42u8; 32]); + app.rebuild_router(); + + // Set identity to did_web (not complete -- so guard passes but mode check fails) + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/setup/identity") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"mode": "did_web"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + // GET rotation key should fail for did_web + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/api/setup/rotation-key") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "rotation key export should reject non-plc mode" + ); +} + +// --------------------------------------------------------------------------- +// Resolve identity endpoint +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn resolve_identity_empty_query_returns_empty() { + common::require_db!(); + let app = TestApp::new().await; + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/api/setup/resolve?q=") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + let results = body.as_array().unwrap(); + assert!(results.is_empty(), "empty query should return empty array"); +} + +#[tokio::test] +#[serial] +async fn resolve_identity_with_did_returns_result() { + common::require_db!(); + let app = TestApp::new().await; + + // Resolving a DID that doesn't exist should still return the DID as-is (fallback path) + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .uri("/api/setup/resolve?q=did%3Aplc%3Atestresolver") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + let body = json_body(resp).await; + let results = body.as_array().unwrap(); + assert_eq!(results.len(), 1, "DID input should return one result"); + assert_eq!(results[0]["did"], "did:plc:testresolver"); +} + +// --------------------------------------------------------------------------- +// PLC register endpoint +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn plc_register_creates_did() { + common::require_db!(); + let mut app = TestApp::new().await; + let plc_store = plc::setup_mock_plc(&app.mock_server).await; + + app.state.config.token_encryption_key = Some([0x42u8; 32]); + app.rebuild_router(); + + // Set identity to did_plc via the setup endpoint (generates both keys) + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/setup/identity") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"mode": "did_plc"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + // Register the DID via the PLC directory + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/setup/plc/register") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::OK, + "plc_register should return 200 with the DID" + ); + let body = json_body(resp).await; + let did = body["did"].as_str().unwrap(); + assert!( + did.starts_with("did:plc:"), + "DID should start with did:plc:" + ); + + // Verify the PLC store received the genesis document + let store = plc_store.read().await; + assert!( + store.contains_key(did), + "PLC store should contain the registered DID" + ); + let genesis = store.get(did).unwrap(); + assert_eq!(genesis["type"], "plc_operation"); + assert!(genesis["sig"].is_string(), "genesis should be signed"); +} + +#[tokio::test] +#[serial] +async fn plc_register_rejects_non_plc_mode() { + common::require_db!(); + let mut app = TestApp::new().await; + app.state.config.token_encryption_key = Some([0x42u8; 32]); + app.rebuild_router(); + + // Set identity to did_web + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/setup/identity") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"mode": "did_web"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/setup/plc/register") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "plc_register should reject non-plc mode" + ); +} + +#[tokio::test] +#[serial] +async fn plc_register_rejects_duplicate() { + common::require_db!(); + let mut app = TestApp::new().await; + let _plc_store = plc::setup_mock_plc(&app.mock_server).await; + + app.state.config.token_encryption_key = Some([0x42u8; 32]); + app.rebuild_router(); + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/setup/identity") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"mode": "did_plc"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + // First registration succeeds + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/setup/plc/register") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::OK); + + // Second registration should fail (DID already set) + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/setup/plc/register") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::CONFLICT, + "duplicate plc_register should return 409" + ); +} + +// --------------------------------------------------------------------------- +// Attach auth confirm endpoint +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn attach_auth_confirm_restores_cookie() { + common::require_db!(); + let app = TestApp::new().await; + + // Set up attach_account mode with a known attached DID + let attached_did = "did:plc:attachedaccount"; + happyview::service_identity::upsert_identity( + &app.state.db, + app.state.db_backend, + &happyview::service_identity::IdentityMode::AttachAccount, + None, + None, + None, + Some(attached_did), + ) + .await + .unwrap(); + + // Build a cookie as the attached account (simulating post-OAuth state) + let attached_cookie = + crate::common::auth::admin_cookie_header(attached_did, &app.state.cookie_key); + + // POST attach-auth/confirm to restore the admin's cookie + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/setup/attach-auth/confirm") + .header(attached_cookie.0, attached_cookie.1) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "original_did": &app.admin_did + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::NO_CONTENT, + "attach_auth_confirm should return 204" + ); + + // Verify the Set-Cookie header is present (cookie was restored) + assert!( + resp.headers().contains_key("set-cookie"), + "response should set a new cookie" + ); +} + +#[tokio::test] +#[serial] +async fn attach_auth_confirm_rejects_invalid_original_did() { + common::require_db!(); + let app = TestApp::new().await; + + let attached_did = "did:plc:attachedaccount2"; + happyview::service_identity::upsert_identity( + &app.state.db, + app.state.db_backend, + &happyview::service_identity::IdentityMode::AttachAccount, + None, + None, + None, + Some(attached_did), + ) + .await + .unwrap(); + + let attached_cookie = + crate::common::auth::admin_cookie_header(attached_did, &app.state.cookie_key); + + // Try with empty original_did + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/setup/attach-auth/confirm") + .header(attached_cookie.0, attached_cookie.1) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "original_did": "not-a-did" + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "invalid original_did should be rejected" + ); +} + +#[tokio::test] +#[serial] +async fn attach_auth_confirm_rejects_mismatched_session() { + common::require_db!(); + let app = TestApp::new().await; + + let attached_did = "did:plc:attachedaccount3"; + happyview::service_identity::upsert_identity( + &app.state.db, + app.state.db_backend, + &happyview::service_identity::IdentityMode::AttachAccount, + None, + None, + None, + Some(attached_did), + ) + .await + .unwrap(); + + // Cookie is for the admin user, but attached_account_did is different + let wrong_cookie = app.admin_cookie(); + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/setup/attach-auth/confirm") + .header(wrong_cookie.0, wrong_cookie.1) + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({ + "original_did": &app.admin_did + })) + .unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::UNAUTHORIZED, + "mismatched session should be rejected" + ); +} + +// --------------------------------------------------------------------------- +// PLC request/submit — mode rejection +// --------------------------------------------------------------------------- + +#[tokio::test] +#[serial] +async fn plc_request_rejects_non_attach_account_mode() { + common::require_db!(); + let mut app = TestApp::new().await; + app.state.config.token_encryption_key = Some([0x42u8; 32]); + app.rebuild_router(); + + // Set identity to did_web + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/setup/identity") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"mode": "did_web"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/setup/plc/request") + .body(Body::empty()) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "plc_request should reject non-attach_account mode" + ); +} + +#[tokio::test] +#[serial] +async fn plc_submit_rejects_non_attach_account_mode() { + common::require_db!(); + let mut app = TestApp::new().await; + app.state.config.token_encryption_key = Some([0x42u8; 32]); + app.rebuild_router(); + + // Set identity to did_web + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/setup/identity") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"mode": "did_web"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!(resp.status(), StatusCode::NO_CONTENT); + + let resp = app + .router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/api/setup/plc/submit") + .header("content-type", "application/json") + .body(Body::from( + serde_json::to_vec(&json!({"token": "fake-token"})).unwrap(), + )) + .unwrap(), + ) + .await + .unwrap(); + + assert_eq!( + resp.status(), + StatusCode::BAD_REQUEST, + "plc_submit should reject non-attach_account mode" + ); +} diff --git a/web/playwright.config.ts b/web/playwright.config.ts index dc8e4f2..7e7ebb7 100644 --- a/web/playwright.config.ts +++ b/web/playwright.config.ts @@ -28,6 +28,7 @@ export default defineConfig({ testMatch: [ "service-identity-settings.spec.ts", "lexicon-services.spec.ts", + "proxy-config.spec.ts", ], dependencies: ["setup"], use: { browserName: "chromium" }, @@ -36,6 +37,12 @@ export default defineConfig({ name: "attach-account", testMatch: "setup-attach-account.spec.ts", dependencies: ["post-setup"], + use: { browserName: "chromium", ignoreHTTPSErrors: true }, + }, + { + name: "didplc-setup", + testMatch: "setup-didplc.spec.ts", + dependencies: ["attach-account"], use: { browserName: "chromium" }, }, ], diff --git a/web/src/components/setup/setup-attach-auth.tsx b/web/src/components/setup/setup-attach-auth.tsx index 122dd99..809bccf 100644 --- a/web/src/components/setup/setup-attach-auth.tsx +++ b/web/src/components/setup/setup-attach-auth.tsx @@ -70,7 +70,16 @@ export function SetupAttachAuth({ attachedDid, attachedHandle, onComplete }: Set localStorage.setItem(ATTACH_AUTH_STORAGE_KEY, JSON.stringify(payload)) const handle = attachedHandle ?? attachedDid - window.location.href = `/auth/login?handle=${encodeURIComponent(handle)}` + return fetch(`/auth/login?handle=${encodeURIComponent(handle)}`, { + credentials: "same-origin", + }) + }) + .then((resp) => { + if (!resp.ok) throw new Error("Login request failed") + return resp.json() as Promise<{ url: string }> + }) + .then(({ url }) => { + window.location.href = url }) .catch((e) => { setError(e instanceof Error ? e.message : "Failed to start authentication") diff --git a/web/src/components/setup/setup-wizard.tsx b/web/src/components/setup/setup-wizard.tsx index 8b7de57..f0f1e45 100644 --- a/web/src/components/setup/setup-wizard.tsx +++ b/web/src/components/setup/setup-wizard.tsx @@ -33,7 +33,21 @@ export function SetupWizard() { setCurrentStep("verify") } else if (status.identity_mode) { setIdentityMode(status.identity_mode) - setCurrentStep("configure") + + // Returning from OAuth redirect — localStorage has the pending auth payload. + // Jump directly to attach-auth so SetupAttachAuth can process the callback. + const pendingAuth = localStorage.getItem("happyview_attach_auth") + if (status.identity_mode === "attach_account" && pendingAuth) { + try { + const payload = JSON.parse(pendingAuth) as { attachedDid: string } + setAttachedDid(payload.attachedDid) + setCurrentStep("attach-auth") + } catch { + setCurrentStep("configure") + } + } else { + setCurrentStep("configure") + } } }) .finally(() => setLoading(false)) diff --git a/web/tests/e2e/lexicon-services.spec.ts b/web/tests/e2e/lexicon-services.spec.ts index eda62a8..5df8e55 100644 --- a/web/tests/e2e/lexicon-services.spec.ts +++ b/web/tests/e2e/lexicon-services.spec.ts @@ -1,28 +1,134 @@ import { test, expect } from "@playwright/test" import { loginAsTestAdmin } from "./auth-helper" +const RECORD_LEXICON = { + lexicon: 1, + id: "test.e2e.lexiconservices.item", + defs: { + main: { + type: "record", + key: "tid", + record: { + type: "object", + properties: { + title: { type: "string" }, + }, + }, + }, + }, +} + +const QUERY_LEXICON = { + lexicon: 1, + id: "test.e2e.lexiconservices.listItems", + defs: { + main: { + type: "query", + parameters: { + type: "params", + properties: { + limit: { type: "integer" }, + }, + }, + output: { + encoding: "application/json", + }, + }, + }, +} + +async function seedLexicon( + request: import("@playwright/test").APIRequestContext, + lexiconJson: object, + targetCollection?: string, +) { + const resp = await request.post("/admin/lexicons", { + data: { + lexicon_json: lexiconJson, + backfill: false, + ...(targetCollection ? { target_collection: targetCollection } : {}), + }, + }) + if (!resp.ok()) { + throw new Error(`Failed to seed lexicon: ${resp.status()} ${await resp.text()}`) + } +} + +async function deleteLexicon( + request: import("@playwright/test").APIRequestContext, + nsid: string, +) { + await request.delete(`/admin/lexicons/${nsid}`) +} + test.describe("Lexicon Services", () => { test.beforeEach(async ({ page }) => { await loginAsTestAdmin(page) + + await seedLexicon(page.request, RECORD_LEXICON) + await seedLexicon(page.request, QUERY_LEXICON, RECORD_LEXICON.id) + }) + + test.afterEach(async ({ page }) => { + await deleteLexicon(page.request, QUERY_LEXICON.id) + await deleteLexicon(page.request, RECORD_LEXICON.id) }) - test("service entry appears in lexicon services sheet", async ({ page }) => { + test("create service entry and view lexicon services sheet", async ({ page }) => { + // Create a service entry via the settings page await page.goto("/dashboard/settings/service-identity") - const addButton = page.getByRole("button", { name: /add.*entry|new.*entry/i }) - if (await addButton.isVisible({ timeout: 5000 }).catch(() => false)) { - await addButton.click() - await expect(page.getByText(/service entry/i)).toBeVisible() - } + const fragmentInput = page.getByLabel(/fragment/i) + await expect(fragmentInput).toBeVisible({ timeout: 5000 }) + + await fragmentInput.fill("#lextest") + const typeInput = page.getByLabel(/service type/i) + await typeInput.fill("TestView") + + const addButton = page.getByRole("button", { name: "Add" }) + await expect(addButton).toBeEnabled({ timeout: 3000 }) + await addButton.click() + + await expect(page.getByText("#lextest")).toBeVisible({ timeout: 5000 }) + + // Navigate to the query lexicon's detail page await page.goto("/dashboard/lexicons") - const firstLexicon = page.locator("table tbody tr").first() - if (await firstLexicon.isVisible({ timeout: 5000 }).catch(() => false)) { - await firstLexicon.click() - const servicesButton = page.getByRole("button", { name: /services/i }) - if (await servicesButton.isVisible({ timeout: 5000 }).catch(() => false)) { - await servicesButton.click() - await expect(page.getByText(/service/i)).toBeVisible() - } + + const queryRow = page.locator("table tbody tr", { + hasText: QUERY_LEXICON.id, + }) + await expect(queryRow).toBeVisible({ timeout: 5000 }) + await queryRow.click() + + // Query lexicons have a Services button + const servicesButton = page.getByRole("button", { name: /services/i }) + await expect(servicesButton).toBeVisible({ timeout: 5000 }) + await servicesButton.click() + + // Verify the services sheet opens + const sheet = page.locator("[data-slot='sheet-content']") + await expect(sheet).toBeVisible({ timeout: 5000 }) + await expect(sheet.getByRole("heading", { name: "Services" })).toBeVisible() + + // Should show either service entries or "No services have access" + const hasServices = await sheet + .locator("table tbody tr") + .first() + .isVisible({ timeout: 3000 }) + .catch(() => false) + const hasNoServicesMessage = await sheet + .getByText(/no services have access/i) + .isVisible() + .catch(() => false) + + expect(hasServices || hasNoServicesMessage).toBe(true) + + // Clean up the service entry + await page.goto("/dashboard/settings/service-identity") + const deleteButton = page.getByRole("button", { name: /delete #lextest/i }) + if (await deleteButton.isVisible({ timeout: 3000 }).catch(() => false)) { + await deleteButton.click() + await expect(page.getByText("#lextest")).not.toBeVisible({ timeout: 5000 }) } }) }) diff --git a/web/tests/e2e/proxy-config.spec.ts b/web/tests/e2e/proxy-config.spec.ts new file mode 100644 index 0000000..6e075ff --- /dev/null +++ b/web/tests/e2e/proxy-config.spec.ts @@ -0,0 +1,72 @@ +import { test, expect } from "@playwright/test" +import { loginAsTestAdmin } from "./auth-helper" + +test.describe("Proxy Config Settings", () => { + test.beforeEach(async ({ page }) => { + await loginAsTestAdmin(page) + await page.goto("/dashboard/settings/xrpc-proxy") + }) + + test("displays current proxy mode", async ({ page }) => { + const openRadio = page.locator("input[type='radio'][value='open']") + await expect(openRadio).toBeVisible({ timeout: 5000 }) + await expect(openRadio).toBeChecked() + }) + + test("switch to allowlist mode and add pattern", async ({ page }) => { + // Select Allowlist radio + const allowlistRadio = page.locator("input[type='radio'][value='allowlist']") + await expect(allowlistRadio).toBeVisible({ timeout: 5000 }) + await allowlistRadio.check() + + // Verify NSID input appears + const nsidInput = page.getByPlaceholder("com.example.feed.*") + await expect(nsidInput.first()).toBeVisible({ timeout: 3000 }) + + // Fill with a pattern + await nsidInput.first().fill("com.example.*") + + // Save + const saveButton = page.getByRole("button", { name: "Save changes" }) + await saveButton.click() + + // Wait for success notice + await expect(page.getByText("Proxy settings saved.")).toBeVisible({ timeout: 5000 }) + + // Reload and verify persistence + await page.reload() + + const allowlistAfterReload = page.locator("input[type='radio'][value='allowlist']") + await expect(allowlistAfterReload).toBeChecked({ timeout: 5000 }) + + // Verify the pattern persisted + const nsidInputAfterReload = page.getByPlaceholder("com.example.feed.*").first() + await expect(nsidInputAfterReload).toHaveValue("com.example.*") + }) + + test("switch to disabled mode", async ({ page }) => { + // Select Disabled radio + const disabledRadio = page.locator("input[type='radio'][value='disabled']") + await expect(disabledRadio).toBeVisible({ timeout: 5000 }) + await disabledRadio.check() + + // Save + const saveButton = page.getByRole("button", { name: "Save changes" }) + await saveButton.click() + + // Wait for success notice + await expect(page.getByText("Proxy settings saved.")).toBeVisible({ timeout: 5000 }) + + // Reload and verify persistence + await page.reload() + + const disabledAfterReload = page.locator("input[type='radio'][value='disabled']") + await expect(disabledAfterReload).toBeChecked({ timeout: 5000 }) + + // Restore to Open mode for subsequent tests + const openRadio = page.locator("input[type='radio'][value='open']") + await openRadio.check() + await page.getByRole("button", { name: "Save changes" }).click() + await expect(page.getByText("Proxy settings saved.")).toBeVisible({ timeout: 5000 }) + }) +}) diff --git a/web/tests/e2e/service-identity-settings.spec.ts b/web/tests/e2e/service-identity-settings.spec.ts index 94edf70..961eeb2 100644 --- a/web/tests/e2e/service-identity-settings.spec.ts +++ b/web/tests/e2e/service-identity-settings.spec.ts @@ -7,6 +7,71 @@ test.describe("Service Identity Settings", () => { await page.goto("/dashboard/settings/service-identity") }) + test("manage service entry access mode and xrpcs", async ({ page }) => { + // Create an entry to work with + const fragmentInput = page.getByLabel(/fragment/i) + const typeInput = page.getByLabel(/service type/i) + await expect(fragmentInput).toBeVisible({ timeout: 5000 }) + + await fragmentInput.fill("#e2esheet") + await typeInput.fill("TestView") + + const mainAddButton = page.getByRole("button", { name: "Add" }) + await expect(mainAddButton).toBeEnabled({ timeout: 3000 }) + await mainAddButton.click() + + // Wait for the entry to appear in the table + await expect(page.getByText("#e2esheet")).toBeVisible({ timeout: 5000 }) + + // Click the fragment ID link to open the sheet + await page.getByText("#e2esheet").click() + + // Verify the sheet opens with the correct title + const sheet = page.locator("[data-slot='sheet-content']") + await expect(sheet).toBeVisible({ timeout: 5000 }) + await expect(sheet.getByText("#e2esheet")).toBeVisible() + + // Click "Specific XRPCs" to change access mode + const specificButton = sheet.getByRole("button", { name: "Specific XRPCs" }) + await specificButton.click() + + // Fill the XRPC input (placeholder "games.birb.chess.getGame") + const xrpcInput = sheet.getByPlaceholder("games.birb.chess.getGame") + await expect(xrpcInput).toBeVisible({ timeout: 3000 }) + await xrpcInput.fill("com.example.test.query") + + // Click the "Add" button inside the sheet + const sheetAddButton = sheet.getByRole("button", { name: "Add" }) + await sheetAddButton.click() + + // Verify the XRPC appears in the sheet's table + await expect(sheet.getByText("com.example.test.query")).toBeVisible({ timeout: 5000 }) + + // Click Save + const saveButton = sheet.getByRole("button", { name: "Save" }) + await saveButton.click() + + // Wait for the sheet to close + await expect(sheet).not.toBeVisible({ timeout: 5000 }) + + // Reload and reopen to verify persistence + await page.reload() + await expect(page.getByText("#e2esheet")).toBeVisible({ timeout: 5000 }) + await page.getByText("#e2esheet").click() + + const sheetAfterReload = page.locator("[data-slot='sheet-content']") + await expect(sheetAfterReload).toBeVisible({ timeout: 5000 }) + await expect(sheetAfterReload.getByText("com.example.test.query")).toBeVisible({ timeout: 5000 }) + + // Clean up: delete the entry using the button in the sheet footer + const deleteButton = sheetAfterReload.getByRole("button", { name: /delete service/i }) + await deleteButton.click() + + // Wait for sheet to close, then verify entry is removed from the table + await expect(sheetAfterReload).not.toBeVisible({ timeout: 5000 }) + await expect(page.getByRole("button", { name: /delete #e2esheet/i })).not.toBeVisible({ timeout: 5000 }) + }) + test("add and remove a service entry", async ({ page }) => { const fragmentInput = page.getByLabel(/fragment/i) const typeInput = page.getByLabel(/service type/i) diff --git a/web/tests/e2e/setup-attach-account.spec.ts b/web/tests/e2e/setup-attach-account.spec.ts index d5f98bd..5df34b7 100644 --- a/web/tests/e2e/setup-attach-account.spec.ts +++ b/web/tests/e2e/setup-attach-account.spec.ts @@ -70,6 +70,85 @@ test.describe("Setup - Attach Account", () => { ).toBeVisible() }) + test("full OAuth flow completes through PDS authorization", async ({ page }) => { + await resetServiceIdentity() + await loginAsTestAdmin(page) + await page.goto("/setup") + + // Select "Attach existing account" + await expect( + page.getByText(/how should this appview be identified/i), + ).toBeVisible({ timeout: 10000 }) + await page.getByText(/attach existing account/i).click() + await page.getByRole("button", { name: /continue/i }).click() + + // Enter the DID directly without selecting from the typeahead so + // attachedHandle stays null and the OAuth flow uses the DID. + // Handle resolution for .test domains won't work from inside Docker. + const identifierInput = page.getByLabel(/account identifier/i) + await expect(identifierInput).toBeVisible({ timeout: 5000 }) + await identifierInput.fill(account.did) + + // Dismiss any typeahead dropdown that appears + await page.keyboard.press("Escape") + + const continueButton = page.getByRole("button", { name: /continue/i }) + await expect(continueButton).toBeEnabled({ timeout: 5000 }) + await continueButton.click() + + // Reach the authenticate step + await expect( + page.getByText(/authenticate attached account/i), + ).toBeVisible({ timeout: 10000 }) + + // Click "Authenticate as @handle" — this triggers the OAuth flow: + // 1. Frontend fetches /auth/login?handle= to get the authorization URL + // 2. HappyView's backend makes a PAR request to the PDS via Caddy (HTTPS) + // 3. Frontend redirects to the PDS OAuth login page + const authButton = page.getByRole("button", { + name: /authenticate as/i, + }) + await authButton.click() + + // Wait for redirect to PDS OAuth login page (served via Caddy at pds.localhost) + await page.waitForURL(/pds\.localhost/, { timeout: 30000 }) + + // Fill in credentials on the PDS OAuth login form + // The PDS login page has: username input (#username), password input (#password), submit button + const usernameInput = page.locator("#username") + await expect(usernameInput).toBeVisible({ timeout: 10000 }) + await usernameInput.fill(account.handle) + + const passwordInput = page.locator("#password") + await expect(passwordInput).toBeVisible({ timeout: 5000 }) + await passwordInput.fill("Test-password-e2e-123") + + // Submit the login form + await page.locator("button[type='submit']").click() + + // After login, the PDS may show a consent screen or auto-redirect. + // Wait for either the consent page or the redirect back to HappyView. + const consentOrCallback = await Promise.race([ + page.waitForURL(/127\.0\.0\.1:3200/, { timeout: 30000 }).then(() => "callback" as const), + page.locator("text=/authorize/i").waitFor({ timeout: 10000 }).then(() => "consent" as const).catch(() => null), + ]) + + if (consentOrCallback === "consent") { + // Click the authorize button on the consent page + const authorizeButton = page.locator("button", { hasText: /authorize/i }).last() + await authorizeButton.click() + await page.waitForURL(/127\.0\.0\.1:3200/, { timeout: 15000 }) + } + + // We're back on HappyView after the OAuth callback. + // The setup-attach-auth component detects the return via localStorage + // and calls confirmAttachAuth to restore the admin session. + // Then the wizard advances to the "verify" step. + await expect( + page.getByRole("tab", { name: "Verify", selected: true }), + ).toBeVisible({ timeout: 15000 }) + }) + // Restore setup state for subsequent tests test.afterAll(async ({ browser }) => { const page = await browser.newPage() diff --git a/web/tests/e2e/setup-didplc.spec.ts b/web/tests/e2e/setup-didplc.spec.ts new file mode 100644 index 0000000..e7e6613 --- /dev/null +++ b/web/tests/e2e/setup-didplc.spec.ts @@ -0,0 +1,81 @@ +import { test, expect } from "@playwright/test" +import { resetServiceIdentity } from "./auth-helper" + +test.describe("Setup - did:plc", () => { + test.beforeAll(async () => { + await resetServiceIdentity() + }) + + test("did:plc flow completes successfully", async ({ page }) => { + await page.goto("/setup") + + // Select "Create did:plc" + await expect( + page.getByText(/how should this appview be identified/i), + ).toBeVisible({ timeout: 10000 }) + + await page.getByText("Create did:plc").click() + await page.getByRole("button", { name: /continue/i }).click() + + // The configure step shows "Create did:plc" card with Continue button + await expect(page.getByText("Create did:plc").first()).toBeVisible({ timeout: 5000 }) + await page.getByRole("button", { name: /continue/i }).click() + + // Wait for either "Registering DID..." or the result + const registeringText = page.getByText("Registering DID...") + const exportKeyText = page.getByText("Export Rotation Key") + const registrationFailed = page.getByText("Registration Failed") + + // Wait for the registration to start or complete + await expect( + registeringText.or(exportKeyText).or(registrationFailed), + ).toBeVisible({ timeout: 10000 }) + + // If registration is in progress, wait for it to finish + if (await registeringText.isVisible().catch(() => false)) { + await expect( + exportKeyText.or(registrationFailed), + ).toBeVisible({ timeout: 30000 }) + } + + // If registration failed, skip the rest of the test + if (await registrationFailed.isVisible().catch(() => false)) { + test.skip(true, "PLC registration failed in test environment") + return + } + + // Verify "Download Rotation Key" button is visible + await expect( + page.getByRole("button", { name: /download rotation key/i }), + ).toBeVisible() + + // Click Continue to complete setup + await page.getByRole("button", { name: /continue/i }).click() + + // Verify setup completes + await expect(page.getByText("Setup Complete")).toBeVisible({ timeout: 5000 }) + }) + + // Restore setup state for subsequent tests + test.afterAll(async ({ browser }) => { + await resetServiceIdentity() + const page = await browser.newPage() + try { + await page.goto( + (process.env.PLAYWRIGHT_BASE_URL || "http://127.0.0.1:3200") + "/setup", + ) + const notExposedCard = page.getByText(/not exposed/i) + if ( + await notExposedCard.isVisible({ timeout: 5000 }).catch(() => false) + ) { + await notExposedCard.click() + await page.getByRole("button", { name: /continue/i }).click() + await expect( + page.getByText("Setup Complete"), + ).toBeVisible({ timeout: 5000 }) + } + } finally { + await page.close() + } + }) +}) diff --git a/web/tests/e2e/setup-wizard.spec.ts b/web/tests/e2e/setup-wizard.spec.ts index 2f6550c..6f18416 100644 --- a/web/tests/e2e/setup-wizard.spec.ts +++ b/web/tests/e2e/setup-wizard.spec.ts @@ -1,6 +1,11 @@ import { test, expect } from "@playwright/test" +import { resetServiceIdentity } from "./auth-helper" test.describe("Setup Wizard", () => { + test.beforeAll(async () => { + await resetServiceIdentity() + }) + test("did:web flow completes successfully", async ({ page }) => { await page.goto("/setup")