From 24c6f772f7fdb2faf9d86da487953b39bcd97a4b Mon Sep 17 00:00:00 2001 From: Hare Date: Fri, 16 Oct 2026 10:48:51 +0900 Subject: [PATCH] fix: serve entry shells independently of workspace catalog --- crates/workspace-server/src/server.rs | 26 +++- tests/e2e/tests/auth_passkeys.rs | 182 +++++++++++++++++++++++++- 2 files changed, 202 insertions(+), 6 deletions(-) diff --git a/crates/workspace-server/src/server.rs b/crates/workspace-server/src/server.rs index 9c337d680..dbc8bbc41 100644 --- a/crates/workspace-server/src/server.rs +++ b/crates/workspace-server/src/server.rs @@ -3387,9 +3387,15 @@ async fn dispatch_workspace_request( request: Request, ) -> Response { let path = request.uri().path().to_owned(); - // Hosting setup/settings belong to the Server, including zero or multiple - // Workspaces. Serving their public shell does not grant their API authority. - if path.starts_with("/hosting/") || path == "/activate" { + // Public and authentication shells belong to the Server regardless of the + // catalog. Their delivery neither selects a Workspace nor grants API access. + if matches!( + path.as_str(), + "/" | "/dashboard" | "/login" | "/register" | "/account" | "/activate" | "/login/device" + ) || path.starts_with("/hosting/") + || path.starts_with("/_app/") + || path.starts_with("/assets/") + { return serve_server_static_shell(&api, &path).await; } let workspace_id = scoped_workspace_id(&path); @@ -40394,7 +40400,7 @@ mod tests { } #[tokio::test] - async fn hosting_shell_is_server_scoped_with_zero_one_or_multiple_workspaces() { + async fn entry_shells_are_server_scoped_with_zero_one_or_multiple_workspaces() { let dir = tempfile::tempdir().unwrap(); let mut config = test_server_config(dir.path()); let assets = dir.path().join("static"); @@ -40415,7 +40421,17 @@ mod tests { .await .unwrap(); } - for path in ["/hosting/settings", "/hosting/setup"] { + for path in [ + "/", + "/dashboard", + "/login", + "/register", + "/account", + "/activate", + "/login/device?user_code=ABCD-EFGH", + "/hosting/settings", + "/hosting/setup", + ] { let response = app .clone() .oneshot(Request::builder().uri(path).body(Body::empty()).unwrap()) diff --git a/tests/e2e/tests/auth_passkeys.rs b/tests/e2e/tests/auth_passkeys.rs index a7e88be29..39144fa41 100644 --- a/tests/e2e/tests/auth_passkeys.rs +++ b/tests/e2e/tests/auth_passkeys.rs @@ -1,9 +1,11 @@ //! 隔離した実yoi-serverのhost CLI・HTTP APIで、管理者初期設定、登録可否の永続化、 -//! 明示handle login、本人の鍵追加、限定activation、Workspace所有境界を検証する。DB・認証・routerは本番実装を使い、 +//! 明示handle login、本人の鍵追加、限定activation、Workspace所有・catalog選択境界を検証する。DB・認証・routerは本番実装を使い、 //! authenticatorだけをSoftPasskeyに置換する(browser UI/OS authenticatorは対象外)。 //! `cargo test -p yoi-e2e --features e2e --test auth_passkeys`で明示選択する。 //! binary供給、HOME/XDG/DB/動的port、readiness、ログ、RAII回収は共有harnessが所有する。 //! HTTPはproxy/redirect/cookie storeなし、各要求にtimeoutを設ける。 +//! `/`・`/login`・`/dashboard`のstatic shellは対象外。実Server CLIにはstatic assets設定経路がなく、 +//! shell表示/browser遷移は本番routerのin-process検証やbrowser側の合成HTTP検証と区別する。 use std::error::Error; use std::fs::OpenOptions; @@ -683,6 +685,184 @@ fn disabled_registration_preserves_existing_login_self_addition_and_workspace_ow Ok(()) } +#[test] +fn workspace_catalog_zero_one_multiple_preserves_explicit_selection_and_actor_ownership() -> Result +{ + let mut environment = WorkerE2eEnvironment::new()?; + eprintln!( + "Workspace catalog E2E artifacts: {}", + environment.artifacts_dir.display() + ); + environment.start_empty_backend()?; + assert!(environment.workspace_id.is_none()); + assert!(environment.runtime_pid().is_none()); + let base = environment + .server_url + .as_deref() + .ok_or("missing server URL")?; + let artifacts = &environment.artifacts_dir; + let client = http_client()?; + let empty: Value = decode(send( + client.get(format!("{base}/api/workspaces")), + "fresh empty Server exposes an empty anonymous catalog", + artifacts, + )?)?; + assert_eq!(empty, json!([])); + + let request = json!({ + "operation_key": format!("catalog-e2e-{}-first", environment.runtime_id), + "display_name": "Explicit first Workspace" + }); + rejected( + send( + client + .post(format!("{base}/api/workspaces")) + .header(ORIGIN, BROWSER_ORIGIN) + .json(&request), + "anonymous create cannot bootstrap an owner", + artifacts, + )?, + StatusCode::FORBIDDEN, + "authenticated owner", + )?; + let mut admin_key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let (_, admin_cookie) = host_admin_login(&environment, &client, &mut admin_key)?; + set_policy(&client, base, artifacts, &admin_cookie, true)?; + let mut key = WebauthnAuthenticator::new(SoftPasskey::new(true)); + let (registered, registration_cookie, _) = register( + &client, + base, + artifacts, + &environment.account_handle, + &mut key, + )?; + logout(&client, base, artifacts, ®istration_cookie)?; + let (logged_in, cookie) = login( + &client, + base, + artifacts, + &environment.account_handle, + &mut key, + )?; + assert_eq!(logged_in.user, registered.user); + browser_identity(&client, base, artifacts, &cookie, ®istered.user)?; + let empty: Value = decode(send( + client + .get(format!("{base}/api/workspaces")) + .header(COOKIE, &cookie), + "registration and explicit passkey login do not implicitly create a Workspace", + artifacts, + )?)?; + assert_eq!(empty, json!([])); + + let mut expected = Vec::new(); + for (index, display_name) in ["Explicit first Workspace", "Explicit second Workspace"] + .into_iter() + .enumerate() + { + let mut request = request.clone(); + if index == 1 { + request["operation_key"] = + json!(format!("catalog-e2e-{}-second", environment.runtime_id)); + request["display_name"] = json!(display_name); + } + let response = send( + client + .post(format!("{base}/api/workspaces")) + .header(COOKIE, &cookie) + .header(ORIGIN, BROWSER_ORIGIN) + .json(&request), + "authenticated actor explicitly creates a Workspace without a Repository", + artifacts, + )?; + assert_eq!(response.status(), StatusCode::CREATED); + let created: Value = decode(response)?; + assert_eq!( + created["workspace"]["owner_account_id"], + registered.user.account_id + ); + assert_eq!(created["workspace"]["display_name"], display_name); + assert_eq!(created["replayed"], false); + assert!(created["repository"].is_null()); + let id = created["workspace"]["workspace_id"] + .as_str() + .ok_or("missing Workspace ID")? + .to_owned(); + assert!(!expected.iter().any(|(previous, _)| previous == &id)); + expected.push((id, display_name)); + + let listed: Value = decode(send( + client + .get(format!("{base}/api/workspaces")) + .header(COOKIE, &cookie), + "catalog rereads all explicitly created Workspaces without selecting one", + artifacts, + )?)?; + let items = listed.as_array().ok_or("catalog is not an array")?; + assert_eq!(items.len(), expected.len()); + // catalog順序に依存せず、返された各IDで独立したWorkspace contextを選択する。 + for (id, name) in &expected { + let item = items + .iter() + .find(|item| item["workspace_id"] == *id) + .ok_or("created Workspace is absent from catalog")?; + assert_eq!(item["display_name"], *name); + assert_eq!(item["owner_account_id"], registered.user.account_id); + let selected: Value = decode(send( + client + .get(format!("{base}/api/w/{id}/workspace")) + .header(COOKIE, &cookie), + "explicit catalog ID selects the matching Workspace context", + artifacts, + )?)?; + assert_eq!(selected["workspace_id"], *id); + assert_eq!(selected["display_name"], *name); + assert_eq!(selected["record_authority"], "server_db"); + assert_eq!(selected["permissions"]["manage_repositories"], true); + let anonymous = send( + client.get(format!("{base}/api/w/{id}/workspace")), + "explicit Workspace ID alone does not authenticate an anonymous reader", + artifacts, + )?; + assert_eq!(anonymous.status(), StatusCode::UNAUTHORIZED); + let body = anonymous.text()?; + assert!(!body.contains(id)); + assert!(!body.contains(name)); + } + rejected( + send( + client.get(format!("{base}/api/workspaces")), + "nonempty catalog is not exposed to anonymous visitors", + artifacts, + )?, + StatusCode::UNAUTHORIZED, + "authentication required", + )?; + let admin_catalog: Value = decode(send( + client + .get(format!("{base}/api/workspaces")) + .header(COOKIE, &admin_cookie), + "hosting administrator catalog does not include another actor's Workspaces", + artifacts, + )?)?; + assert_eq!(admin_catalog, json!([])); + } + let unscoped = send( + client + .get(format!("{base}/api/workspace")) + .header(COOKIE, &cookie), + "multiple Workspaces cannot silently select the first unscoped API context", + artifacts, + )?; + assert_eq!(unscoped.status(), StatusCode::NOT_FOUND); + browser_identity(&client, base, artifacts, &cookie, ®istered.user)?; + logout(&client, base, artifacts, &cookie)?; + let report = environment.cleanup()?; + assert!(report.cleanup_success); + assert!(!report.fixture_root_exists_after); + Ok(()) +} + fn device_login_pending(client: &Client, base: &str, artifacts: &Path, flow: &Value) -> Result { let pending: Value = decode(send( client