fix: serve entry shells independently of workspace catalog
This commit is contained in:
@@ -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())
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user