chore: merge develop into hare/develop
This commit is contained in:
@@ -170,6 +170,11 @@ macro_rules! impl_openapi_schema {
|
||||
impl_openapi_schema!(
|
||||
AuthBootstrapUserRequest,
|
||||
AuthPublicConfig,
|
||||
HostingSettings,
|
||||
HostingAccess,
|
||||
ProvisionalAccountRequest,
|
||||
ActivationCodeResponse,
|
||||
ActivationOptionsRequest,
|
||||
AuthUserResponse,
|
||||
DeviceLoginApproveRequest,
|
||||
DeviceLoginApproveResponse,
|
||||
@@ -515,6 +520,60 @@ pub trait ServerApi {
|
||||
#[get("/api/auth/config", status = 200, error_status = 400)]
|
||||
async fn auth_config(&self) -> Result<AuthPublicConfig, RepositoryApiError>;
|
||||
|
||||
#[get("/api/auth/registration-policy", status = 200, error_status = 400)]
|
||||
async fn auth_registration_policy(&self) -> Result<HostingSettings, RepositoryApiError>;
|
||||
|
||||
#[get("/api/hosting/access", status = 200, error_status = 403, additional_error_statuses = [400, 500], bearer_auth = true, browser_auth = true)]
|
||||
async fn hosting_access(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
) -> Result<HostingAccess, RepositoryApiError>;
|
||||
|
||||
#[get("/api/hosting/settings", status = 200, error_status = 403, additional_error_statuses = [400, 500], bearer_auth = true, browser_auth = true)]
|
||||
async fn hosting_settings(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
) -> Result<HostingSettings, RepositoryApiError>;
|
||||
|
||||
#[put("/api/hosting/settings", status = 200, error_status = 403, additional_error_statuses = [400, 500], bearer_auth = true, browser_auth = true)]
|
||||
async fn hosting_settings_update(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[body] request: HostingSettings,
|
||||
) -> Result<HostingSettings, RepositoryApiError>;
|
||||
|
||||
#[post("/api/hosting/accounts", status = 200, error_status = 400, additional_error_statuses = [403, 500], bearer_auth = true, browser_auth = true)]
|
||||
async fn hosting_account_create(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[body] request: ProvisionalAccountRequest,
|
||||
) -> Result<ActivationCodeResponse, RepositoryApiError>;
|
||||
|
||||
#[post("/api/hosting/accounts/activation-code", status = 200, error_status = 400, additional_error_statuses = [403, 500], bearer_auth = true, browser_auth = true)]
|
||||
async fn hosting_activation_code_reissue(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[body] request: ProvisionalAccountRequest,
|
||||
) -> Result<ActivationCodeResponse, RepositoryApiError>;
|
||||
|
||||
#[post("/api/auth/activation/options", status = 200, error_status = 400, additional_error_statuses = [500])]
|
||||
async fn auth_activation_options(
|
||||
&self,
|
||||
#[extension] context: ServerRequestContext,
|
||||
#[body] request: ActivationOptionsRequest,
|
||||
) -> Result<PasskeyRegistrationOptionsResponse, RepositoryApiError>;
|
||||
|
||||
#[post(
|
||||
"/api/auth/activation/complete",
|
||||
responses = [(status = 200, body = AuthUserResponse, headers = [("set-cookie", SetCookieHeader)])],
|
||||
error_status = 400,
|
||||
additional_error_statuses = [500]
|
||||
)]
|
||||
async fn auth_activation_complete(
|
||||
&self,
|
||||
#[body] request: PasskeyRegistrationCompleteRequest,
|
||||
) -> Result<server_api_responses::AuthActivationComplete, RepositoryApiError>;
|
||||
|
||||
#[post(
|
||||
"/api/auth/bootstrap-user",
|
||||
status = 200,
|
||||
@@ -3846,6 +3905,76 @@ pub struct AuthPublicConfig {
|
||||
pub cookie_name: String,
|
||||
}
|
||||
|
||||
/// The only public registration policy. No administrator identities or host secrets are exposed.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct HostingSettings {
|
||||
pub self_registration_enabled: bool,
|
||||
}
|
||||
|
||||
/// Hosting authority for the current authenticated human, not Workspace permissions.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct HostingAccess {
|
||||
pub is_admin: bool,
|
||||
}
|
||||
|
||||
/// A hosting administrator reserves the same normalized ID used by passkey login.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ProvisionalAccountRequest {
|
||||
pub handle: String,
|
||||
}
|
||||
|
||||
/// The only response allowed to disclose an activation code. Clients must keep it
|
||||
/// out of URLs, persistent browser storage and diagnostics. A lost response is
|
||||
/// recovered by reissuing, which invalidates the previous code and its ceremonies.
|
||||
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ActivationCodeResponse {
|
||||
pub user: AuthenticatedUser,
|
||||
pub code: String,
|
||||
pub expires_at: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ActivationCodeResponse {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("ActivationCodeResponse")
|
||||
.field("user", &self.user)
|
||||
.field("code", &"[REDACTED]")
|
||||
.field("expires_at", &self.expires_at)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Proof for a pending account's initial passkey ceremony, never a bearer token
|
||||
/// or browser session. No caller-selected account ID or enrollment purpose is accepted.
|
||||
#[derive(Clone, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ActivationOptionsRequest {
|
||||
pub handle: String,
|
||||
pub code: String,
|
||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||
pub browser_origin: Option<String>,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ActivationOptionsRequest {
|
||||
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
formatter
|
||||
.debug_struct("ActivationOptionsRequest")
|
||||
.field("handle", &self.handle)
|
||||
.field("code", &"[REDACTED]")
|
||||
.field("browser_origin", &self.browser_origin)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
/// Authentication method that established the current request actor.
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, JsonSchema)]
|
||||
#[cfg_attr(feature = "typescript", derive(ts_rs::TS))]
|
||||
@@ -11395,6 +11524,11 @@ pub fn auth_api_typescript() -> String {
|
||||
let config = ts_rs::Config::default();
|
||||
let declarations = [
|
||||
AuthPublicConfig::decl(&config),
|
||||
HostingSettings::decl(&config),
|
||||
HostingAccess::decl(&config),
|
||||
ProvisionalAccountRequest::decl(&config),
|
||||
ActivationCodeResponse::decl(&config),
|
||||
ActivationOptionsRequest::decl(&config),
|
||||
ActorAuthMethod::decl(&config),
|
||||
AuthenticatedUser::decl(&config),
|
||||
RequestActor::decl(&config),
|
||||
@@ -14380,6 +14514,9 @@ mod openapi_artifact_tests {
|
||||
const PUBLIC_UNAUTHENTICATED: &[&str] = &[
|
||||
"health",
|
||||
"auth_config",
|
||||
"auth_registration_policy",
|
||||
"auth_activation_options",
|
||||
"auth_activation_complete",
|
||||
"auth_bootstrap_user",
|
||||
"auth_passkey_registration_options",
|
||||
"auth_passkey_registration_complete",
|
||||
@@ -14802,3 +14939,47 @@ mod workdir_error_diagnostic_tests {
|
||||
assert!(schema["properties"].get("denial_reason").is_none());
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod activation_contract_tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn activation_inputs_reject_caller_selected_authority_and_debug_redacts_codes() {
|
||||
for field in [
|
||||
"account_id",
|
||||
"user_id",
|
||||
"purpose",
|
||||
"is_admin",
|
||||
"authentication",
|
||||
] {
|
||||
let mut body = serde_json::json!({"handle":"new-user","code":"activation-secret"});
|
||||
body[field] = serde_json::json!("caller-controlled");
|
||||
assert!(serde_json::from_value::<ActivationOptionsRequest>(body).is_err());
|
||||
let mut body = serde_json::json!({"handle":"new-user"});
|
||||
body[field] = serde_json::json!("caller-controlled");
|
||||
assert!(serde_json::from_value::<ProvisionalAccountRequest>(body).is_err());
|
||||
}
|
||||
let request = ActivationOptionsRequest {
|
||||
handle: "new-user".into(),
|
||||
code: "activation-secret".into(),
|
||||
browser_origin: None,
|
||||
};
|
||||
assert!(!format!("{request:?}").contains(&request.code));
|
||||
let response = ActivationCodeResponse {
|
||||
user: AuthenticatedUser {
|
||||
user_id: "u".into(),
|
||||
account_id: "a".into(),
|
||||
handle: "new-user".into(),
|
||||
display_name: "new-user".into(),
|
||||
},
|
||||
code: "activation-secret".into(),
|
||||
expires_at: "2030-01-01T00:00:00Z".into(),
|
||||
};
|
||||
assert!(!format!("{response:?}").contains(&response.code));
|
||||
assert_eq!(
|
||||
serde_json::to_value(&response).unwrap()["code"],
|
||||
"activation-secret"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,203 @@
|
||||
//! Activation codes authorize only a first passkey enrollment, never a normal actor.
|
||||
//!
|
||||
//! Options do not consume the code: cancellation/reload may resume with ID and code.
|
||||
//! Each reissue replaces the issuance identity, invalidating every previous ceremony.
|
||||
//! Credential insertion and removal of the pending row share the store's write transaction.
|
||||
//! Only hashes are persisted; losing the one issuance response requires explicit reissue.
|
||||
|
||||
use super::*;
|
||||
use ring::rand::{SecureRandom, SystemRandom};
|
||||
|
||||
/// Plaintext is returned only to the issuing administrator, never through user records.
|
||||
#[derive(Clone)]
|
||||
pub struct ActivationCodeIssue {
|
||||
pub user: UserRecord,
|
||||
pub code: String,
|
||||
pub expires_at: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for ActivationCodeIssue {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("ActivationCodeIssue")
|
||||
.field("user", &self.user)
|
||||
.field("code", &"[REDACTED]")
|
||||
.field("expires_at", &self.expires_at)
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
fn denied() -> Error {
|
||||
crate::auth::auth_error(
|
||||
"activation_code_invalid",
|
||||
"activation code is invalid, expired or locked",
|
||||
)
|
||||
}
|
||||
|
||||
pub(super) fn pending(conn: &Connection, user_id: &str) -> Result<bool> {
|
||||
Ok(conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM user_activations WHERE user_id=?1)",
|
||||
[user_id],
|
||||
|r| r.get(0),
|
||||
)?)
|
||||
}
|
||||
|
||||
pub(super) fn require_active(conn: &Connection, user_id: &str) -> Result<()> {
|
||||
if pending(conn, user_id)? {
|
||||
return Err(crate::auth::auth_error(
|
||||
"activation_required",
|
||||
"a first passkey must be registered before account use",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn require_active_account(conn: &Connection, account_id: &str) -> Result<()> {
|
||||
let pending: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM users u JOIN user_activations p ON p.user_id=u.user_id WHERE u.account_id=?1)", [account_id], |r| r.get(0))?;
|
||||
if pending {
|
||||
return Err(crate::auth::auth_error(
|
||||
"activation_required",
|
||||
"a pending account cannot own a Workspace",
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn require_admin(tx: &Connection, actor: &server_api::RequestActor) -> Result<()> {
|
||||
let allowed: bool = tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM hosting_administrators h JOIN users u ON u.user_id=h.user_id JOIN accounts a ON a.account_id=u.account_id WHERE u.user_id=?1 AND u.account_id=?2 AND a.kind='user' AND NOT EXISTS(SELECT 1 FROM user_activations p WHERE p.user_id=u.user_id))",
|
||||
params![actor.user_id, actor.account_id], |r| r.get(0),
|
||||
)?;
|
||||
if !allowed {
|
||||
return Err(Error::WorkspacePermissionDenied(
|
||||
"hosting_admin_required: a live hosting administrator is required".into(),
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn issue(tx: &Connection, user: UserRecord) -> Result<ActivationCodeIssue> {
|
||||
let mut bytes = [0u8; 32];
|
||||
SystemRandom::new()
|
||||
.fill(&mut bytes)
|
||||
.map_err(|_| Error::Store("activation code entropy unavailable".into()))?;
|
||||
let code: String = bytes.iter().map(|byte| format!("{byte:02x}")).collect();
|
||||
let expires_at = crate::auth::rfc3339_after(chrono::Duration::minutes(15));
|
||||
let issuance_id = crate::auth::new_id("activation");
|
||||
tx.execute("INSERT INTO user_activations(user_id,issuance_id,code_hash,expires_at,failed_attempts) VALUES(?1,?2,?3,?4,0) ON CONFLICT(user_id) DO UPDATE SET issuance_id=excluded.issuance_id,code_hash=excluded.code_hash,expires_at=excluded.expires_at,failed_attempts=0",
|
||||
params![user.user_id, issuance_id, crate::auth::token_hash(&code), expires_at])?;
|
||||
Ok(ActivationCodeIssue {
|
||||
user,
|
||||
code,
|
||||
expires_at,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn create(
|
||||
store: &SqliteWorkspaceStore,
|
||||
actor: &server_api::RequestActor,
|
||||
handle: &str,
|
||||
) -> Result<ActivationCodeIssue> {
|
||||
let handle = crate::auth::normalize_handle(handle)?;
|
||||
store.with_immediate_transaction(|tx| {
|
||||
require_admin(tx, actor)?;
|
||||
let occupied: bool = tx.query_row("SELECT EXISTS(SELECT 1 FROM users WHERE handle=?1) OR EXISTS(SELECT 1 FROM accounts WHERE kind='user' AND handle=?1)", [&handle], |r| r.get(0))?;
|
||||
if occupied { return Err(crate::auth::auth_error("auth_handle_exists", "handle is already registered")); }
|
||||
let now = crate::auth::now_rfc3339();
|
||||
let user = UserRecord { user_id: crate::auth::new_id("user"), account_id: crate::auth::new_id("acct-user"), handle: handle.clone(), display_name: handle.clone(), created_at: now.clone(), updated_at: now.clone() };
|
||||
tx.execute("INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES(?1,'user',?2,?2,?3,?3)", params![user.account_id, handle, now])?;
|
||||
tx.execute("INSERT INTO users(user_id,account_id,handle,display_name,created_at,updated_at) VALUES(?1,?2,?3,?3,?4,?4)", params![user.user_id, user.account_id, handle, now])?;
|
||||
issue(tx, user)
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) fn reissue(
|
||||
store: &SqliteWorkspaceStore,
|
||||
actor: &server_api::RequestActor,
|
||||
handle: &str,
|
||||
) -> Result<ActivationCodeIssue> {
|
||||
let handle = crate::auth::normalize_handle(handle)?;
|
||||
store.with_immediate_transaction(|tx| {
|
||||
require_admin(tx, actor)?;
|
||||
let user = tx
|
||||
.query_row(
|
||||
user_select_sql("WHERE handle=?1").as_str(),
|
||||
[&handle],
|
||||
read_user_record,
|
||||
)
|
||||
.optional()?
|
||||
.ok_or_else(denied)?;
|
||||
if !pending(tx, &user.user_id)? {
|
||||
return Err(denied());
|
||||
}
|
||||
require_pending_target(tx, &user, None)?;
|
||||
issue(tx, user)
|
||||
})
|
||||
}
|
||||
|
||||
/// A server-retained target is still checked against current account and issuance authority.
|
||||
/// Reissue may replace an expired/locked issuance, but completion never may use one.
|
||||
pub(super) fn require_pending_target(
|
||||
conn: &Connection,
|
||||
user: &UserRecord,
|
||||
issuance: Option<&str>,
|
||||
) -> Result<()> {
|
||||
let valid: bool = conn.query_row("SELECT EXISTS(SELECT 1 FROM users u JOIN accounts a ON a.account_id=u.account_id JOIN user_activations p ON p.user_id=u.user_id WHERE u.user_id=?1 AND u.account_id=?2 AND u.handle=?3 AND a.kind='user' AND a.handle=u.handle AND NOT EXISTS(SELECT 1 FROM passkey_credentials c WHERE c.user_id=u.user_id))", params![user.user_id,user.account_id,user.handle], |r| r.get(0))?;
|
||||
if !valid {
|
||||
return Err(denied());
|
||||
}
|
||||
if let Some(issuance) = issuance {
|
||||
let expiry: Option<String> = conn.query_row("SELECT expires_at FROM user_activations WHERE user_id=?1 AND issuance_id=?2 AND failed_attempts<5", params![user.user_id,issuance], |r| r.get(0)).optional()?;
|
||||
if !expiry.is_some_and(|expiry| !crate::auth::is_expired(&expiry)) {
|
||||
return Err(denied());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn verify(
|
||||
store: &SqliteWorkspaceStore,
|
||||
handle: &str,
|
||||
code: &str,
|
||||
) -> Result<PasskeyRegistrationTarget> {
|
||||
let handle = crate::auth::normalize_handle(handle).map_err(|_| denied())?;
|
||||
// Inner result is deliberate: rejected attempts must commit before the public error.
|
||||
store.with_immediate_transaction(|tx| {
|
||||
let user = tx.query_row(user_select_sql("WHERE handle=?1").as_str(), [&handle], read_user_record).optional()?;
|
||||
let Some(user) = user else { return Ok(Err(denied())); };
|
||||
let row: Option<(String,String,String,i64)> = tx.query_row("SELECT issuance_id,code_hash,expires_at,failed_attempts FROM user_activations WHERE user_id=?1", [&user.user_id], |r| Ok((r.get(0)?,r.get(1)?,r.get(2)?,r.get(3)?))).optional()?;
|
||||
let Some((issuance_id,hash,expiry,attempts)) = row else { return Ok(Err(denied())); };
|
||||
if attempts >= 5 || crate::auth::is_expired(&expiry) { return Ok(Err(denied())); }
|
||||
if crate::auth::token_hash(code) != hash {
|
||||
tx.execute("UPDATE user_activations SET failed_attempts=failed_attempts+1 WHERE user_id=?1", [&user.user_id])?;
|
||||
return Ok(Err(denied()));
|
||||
}
|
||||
require_pending_target(tx, &user, Some(&issuance_id))?;
|
||||
Ok(Ok(PasskeyRegistrationTarget::Activation { user, issuance_id }))
|
||||
})?
|
||||
}
|
||||
|
||||
pub(super) fn migrate(conn: &Connection) -> Result<()> {
|
||||
if current_schema_version(conn)? != 99 {
|
||||
return Err(Error::Store(
|
||||
"Activation migration requires schema 99".into(),
|
||||
));
|
||||
}
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute_batch(include_str!("activation_schema.sql"))?;
|
||||
tx.execute(
|
||||
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(100,?1)",
|
||||
["Provisional users and scoped passkey activation"],
|
||||
)?;
|
||||
verify_workspace_resource_constraints(&tx)?;
|
||||
let violations: i64 =
|
||||
tx.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |r| {
|
||||
r.get(0)
|
||||
})?;
|
||||
if violations != 0 {
|
||||
return Err(Error::Store(
|
||||
"Activation migration foreign-key violation".into(),
|
||||
));
|
||||
}
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
CREATE TABLE user_activations (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
issuance_id TEXT NOT NULL UNIQUE,
|
||||
code_hash TEXT NOT NULL,
|
||||
expires_at TEXT NOT NULL,
|
||||
failed_attempts INTEGER NOT NULL DEFAULT 0 CHECK (failed_attempts BETWEEN 0 AND 5)
|
||||
);
|
||||
@@ -0,0 +1,684 @@
|
||||
// Component tests use production store methods and real isolated SQLite files/connections.
|
||||
// WebAuthn verification is outside this suite: credentials enter at the verified-store boundary.
|
||||
// Included in store::tests to reuse genuine retained migration fixtures. Run with `activation_`.
|
||||
|
||||
fn activation_fixture() -> (SqliteWorkspaceStore, server_api::RequestActor) {
|
||||
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
||||
let admin = store.setup_hosting_admin("admin", true).unwrap();
|
||||
let actor = hosting_actor(&admin.user);
|
||||
(store, actor)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activation_options_resume_without_consuming_and_completion_is_one_time() {
|
||||
let (store, admin) = activation_fixture();
|
||||
assert!(!store.hosting_self_registration_enabled().unwrap());
|
||||
let issue = store
|
||||
.create_provisional_user(&admin, " New-Person ")
|
||||
.unwrap();
|
||||
assert_eq!(issue.user.handle, "new-person");
|
||||
assert_eq!(issue.user.display_name, "new-person");
|
||||
assert!(
|
||||
store
|
||||
.is_user_pending_activation(&issue.user.user_id)
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
store.get_user(&issue.user.user_id).unwrap(),
|
||||
Some(issue.user.clone())
|
||||
);
|
||||
let target = store
|
||||
.verify_activation_code("NEW-PERSON", &issue.code)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
store
|
||||
.verify_activation_code("new-person", &issue.code)
|
||||
.unwrap(),
|
||||
target
|
||||
);
|
||||
assert!(store.verify_activation_code("admin", &issue.code).is_err());
|
||||
assert!(
|
||||
store
|
||||
.verify_activation_code("other-person", &issue.code)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.list_passkey_credentials_for_user(&issue.user.user_id)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.is_user_pending_activation(&issue.user.user_id)
|
||||
.unwrap()
|
||||
);
|
||||
store
|
||||
.register_verified_passkey(&target, &hosting_credential(&issue.user, "first-key"))
|
||||
.unwrap();
|
||||
assert!(
|
||||
!store
|
||||
.is_user_pending_activation(&issue.user.user_id)
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.verify_activation_code("new-person", &issue.code)
|
||||
.is_err()
|
||||
);
|
||||
assert!(store.reissue_activation_code(&admin, "new-person").is_err());
|
||||
assert!(
|
||||
store
|
||||
.register_verified_passkey(&target, &hosting_credential(&issue.user, "second-key"))
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
store
|
||||
.list_passkey_credentials_for_user(&issue.user.user_id)
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
assert!(!store.is_hosting_admin(&issue.user.user_id).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activation_invalid_attempts_commit_lockout_across_reopen_and_reissue_resets_it() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("server.db");
|
||||
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
let admin = hosting_actor(&store.setup_hosting_admin("admin", true).unwrap().user);
|
||||
let issue = store.create_provisional_user(&admin, "person").unwrap();
|
||||
let target = store.verify_activation_code("person", &issue.code).unwrap();
|
||||
for expected in 1..=5 {
|
||||
assert!(store.verify_activation_code("person", "wrong").is_err());
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
let attempts: i64 = conn.query_row(
|
||||
"SELECT failed_attempts FROM user_activations WHERE user_id=?1",
|
||||
[&issue.user.user_id],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
assert_eq!(attempts, expected);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
drop(store);
|
||||
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
assert!(store.verify_activation_code("person", &issue.code).is_err());
|
||||
assert!(
|
||||
store
|
||||
.register_verified_passkey(&target, &hosting_credential(&issue.user, "locked-key"))
|
||||
.is_err()
|
||||
);
|
||||
let next = store.reissue_activation_code(&admin, "PERSON").unwrap();
|
||||
assert!(issue.code != next.code, "reissue must replace the secret");
|
||||
assert!(store.verify_activation_code("person", &issue.code).is_err());
|
||||
assert!(
|
||||
store
|
||||
.register_verified_passkey(&target, &hosting_credential(&issue.user, "old-key"))
|
||||
.is_err()
|
||||
);
|
||||
let target = store.verify_activation_code("person", &next.code).unwrap();
|
||||
store
|
||||
.register_verified_passkey(&target, &hosting_credential(&next.user, "fresh-key"))
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activation_expiry_and_reissued_ceremonies_are_rechecked_at_completion() {
|
||||
let (store, admin) = activation_fixture();
|
||||
let issue = store.create_provisional_user(&admin, "person").unwrap();
|
||||
let old = store.verify_activation_code("person", &issue.code).unwrap();
|
||||
let next = store.reissue_activation_code(&admin, "person").unwrap();
|
||||
assert!(
|
||||
store
|
||||
.register_verified_passkey(&old, &hosting_credential(&issue.user, "old-key"))
|
||||
.is_err()
|
||||
);
|
||||
let target = store.verify_activation_code("person", &next.code).unwrap();
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
conn.execute(
|
||||
"UPDATE user_activations SET expires_at='2000-01-01T00:00:00Z'",
|
||||
[],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(store.verify_activation_code("person", &next.code).is_err());
|
||||
assert!(
|
||||
store
|
||||
.register_verified_passkey(&target, &hosting_credential(&issue.user, "expired-key"))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.is_user_pending_activation(&issue.user.user_id)
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.list_passkey_credentials_for_user(&issue.user.user_id)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activation_credential_failure_rolls_back_pending_removal_and_allows_retry() {
|
||||
let (store, admin) = activation_fixture();
|
||||
let issue = store.create_provisional_user(&admin, "person").unwrap();
|
||||
let target = store.verify_activation_code("person", &issue.code).unwrap();
|
||||
store.with_conn(|conn| { conn.execute_batch("CREATE TRIGGER reject_activation_credential BEFORE INSERT ON passkey_credentials BEGIN SELECT RAISE(ABORT,'injected credential failure'); END;")?; Ok(()) }).unwrap();
|
||||
assert!(
|
||||
store
|
||||
.register_verified_passkey(&target, &hosting_credential(&issue.user, "key"))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.is_user_pending_activation(&issue.user.user_id)
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.list_passkey_credentials_for_user(&issue.user.user_id)
|
||||
.unwrap()
|
||||
.is_empty()
|
||||
);
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
conn.execute_batch("DROP TRIGGER reject_activation_credential")?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let target = store.verify_activation_code("person", &issue.code).unwrap();
|
||||
store
|
||||
.register_verified_passkey(&target, &hosting_credential(&issue.user, "key"))
|
||||
.unwrap();
|
||||
assert!(
|
||||
!store
|
||||
.is_user_pending_activation(&issue.user.user_id)
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activation_completion_checks_account_issuance_user_and_absence_of_credentials() {
|
||||
let (store, admin) = activation_fixture();
|
||||
let issue = store.create_provisional_user(&admin, "person").unwrap();
|
||||
let original = store.verify_activation_code("person", &issue.code).unwrap();
|
||||
let PasskeyRegistrationTarget::Activation { user, issuance_id } = &original else {
|
||||
panic!("wrong purpose")
|
||||
};
|
||||
let mut wrong_user = user.clone();
|
||||
wrong_user.account_id = admin.account_id;
|
||||
let wrong = PasskeyRegistrationTarget::Activation {
|
||||
user: wrong_user,
|
||||
issuance_id: issuance_id.clone(),
|
||||
};
|
||||
assert!(
|
||||
store
|
||||
.register_verified_passkey(&wrong, &hosting_credential(user, "wrong-account"))
|
||||
.is_err()
|
||||
);
|
||||
let mut credential = hosting_credential(user, "wrong-user");
|
||||
credential.user_id = admin.user_id;
|
||||
assert!(
|
||||
store
|
||||
.register_verified_passkey(&original, &credential)
|
||||
.is_err()
|
||||
);
|
||||
// Existing credentials, even if injected outside public admission, may not become recovery.
|
||||
store.with_conn(|conn| { conn.execute("INSERT INTO passkey_credentials(credential_id,user_id,public_key_cose,sign_count,created_at) VALUES('existing',?1,'key',0,'1')", [&user.user_id])?; Ok(()) }).unwrap();
|
||||
assert!(
|
||||
store
|
||||
.register_verified_passkey(&original, &hosting_credential(user, "second"))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.reissue_activation_code(
|
||||
&hosting_actor(&store.get_user_by_handle("admin").unwrap().unwrap()),
|
||||
"person"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(store.is_user_pending_activation(&user.user_id).unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activation_admin_authority_is_live_and_duplicate_handles_preserve_accounts() {
|
||||
let (store, admin) = activation_fixture();
|
||||
let existing = store.setup_hosting_admin("existing", true).unwrap().user;
|
||||
let normal = hosting_actor(&existing);
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
conn.execute(
|
||||
"DELETE FROM hosting_administrators WHERE user_id=?1",
|
||||
[&existing.user_id],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let issue = store.create_provisional_user(&admin, "person").unwrap();
|
||||
assert!(store.create_provisional_user(&normal, "forbidden").is_err());
|
||||
assert!(store.reissue_activation_code(&normal, "person").is_err());
|
||||
let mut fake = admin.clone();
|
||||
fake.account_id = existing.account_id.clone();
|
||||
assert!(store.create_provisional_user(&fake, "fake").is_err());
|
||||
assert!(store.reissue_activation_code(&fake, "person").is_err());
|
||||
for handle in ["existing", "EXISTING", "person"] {
|
||||
assert!(store.create_provisional_user(&admin, handle).is_err());
|
||||
}
|
||||
assert_eq!(store.get_user(&existing.user_id).unwrap(), Some(existing));
|
||||
assert!(store.reissue_activation_code(&admin, "existing").is_err());
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
conn.execute(
|
||||
"DELETE FROM hosting_administrators WHERE user_id=?1",
|
||||
[&admin.user_id],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(store.create_provisional_user(&admin, "revoked").is_err());
|
||||
assert!(store.reissue_activation_code(&admin, "person").is_err());
|
||||
assert_eq!(
|
||||
store.verify_activation_code("person", &issue.code).unwrap(),
|
||||
store.verify_activation_code("person", &issue.code).unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activation_pending_cannot_mint_or_resolve_normal_auth_or_gain_hosting_setup() {
|
||||
let (store, admin) = activation_fixture();
|
||||
let issue = store.create_provisional_user(&admin, "person").unwrap();
|
||||
let session = BrowserSessionRecord {
|
||||
session_id: "session".into(),
|
||||
token_hash: "session-hash".into(),
|
||||
user_id: issue.user.user_id.clone(),
|
||||
created_at: "1".into(),
|
||||
expires_at: crate::auth::rfc3339_after(chrono::Duration::hours(1)),
|
||||
revoked_at: None,
|
||||
};
|
||||
let token = ApiTokenRecord {
|
||||
token_id: "token".into(),
|
||||
token_hash: "token-hash".into(),
|
||||
user_id: issue.user.user_id.clone(),
|
||||
label: "test".into(),
|
||||
created_at: "1".into(),
|
||||
expires_at: None,
|
||||
revoked_at: None,
|
||||
last_used_at: None,
|
||||
};
|
||||
assert!(store.create_browser_session(&session).is_err());
|
||||
assert!(store.create_api_token(&token).is_err());
|
||||
assert!(
|
||||
store
|
||||
.upsert_passkey_credential(&hosting_credential(&issue.user, "bypass"))
|
||||
.is_err()
|
||||
);
|
||||
assert!(store.setup_hosting_admin("person", false).is_err());
|
||||
// Corrupt/legacy credential rows cannot bypass resolution or authorize Addition.
|
||||
store.with_conn(|conn| {
|
||||
conn.execute("INSERT INTO browser_sessions(session_id,token_hash,user_id,created_at,expires_at) VALUES(?1,?2,?3,?4,?5)", params![session.session_id,session.token_hash,session.user_id,session.created_at,session.expires_at])?;
|
||||
conn.execute("INSERT INTO api_tokens(token_id,token_hash,user_id,label,created_at) VALUES(?1,?2,?3,?4,?5)", params![token.token_id,token.token_hash,token.user_id,token.label,token.created_at])?;
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
assert!(
|
||||
store
|
||||
.resolve_browser_session(&session.token_hash)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.resolve_api_token(&token.token_hash)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.register_verified_passkey(
|
||||
&PasskeyRegistrationTarget::Addition {
|
||||
user: issue.user.clone(),
|
||||
authentication: PasskeyAuthenticationBinding::ApiToken(
|
||||
token.token_hash.clone()
|
||||
)
|
||||
},
|
||||
&hosting_credential(&issue.user, "addition")
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.approve_device_login_flow(
|
||||
"unknown",
|
||||
&issue.user.user_id,
|
||||
&token.token_id,
|
||||
"secret",
|
||||
"1"
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
assert!(activation::require_active_account(conn, &issue.user.account_id).is_err());
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
let target = store.verify_activation_code("person", &issue.code).unwrap();
|
||||
store
|
||||
.register_verified_passkey(&target, &hosting_credential(&issue.user, "key"))
|
||||
.unwrap();
|
||||
store
|
||||
.create_browser_session(&BrowserSessionRecord {
|
||||
session_id: "active".into(),
|
||||
token_hash: "active-hash".into(),
|
||||
..session
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
.create_api_token(&ApiTokenRecord {
|
||||
token_id: "active".into(),
|
||||
token_hash: "active-hash".into(),
|
||||
..token
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activation_plaintext_never_persists_and_pending_survives_reopen() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("server.db");
|
||||
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
let admin = hosting_actor(&store.setup_hosting_admin("admin", true).unwrap().user);
|
||||
let issue = store.create_provisional_user(&admin, "person").unwrap();
|
||||
assert!(!format!("{issue:?}").contains(&issue.code));
|
||||
let expiry = chrono::DateTime::parse_from_rfc3339(&issue.expires_at).unwrap();
|
||||
let seconds = (expiry.with_timezone(&chrono::Utc) - chrono::Utc::now()).num_seconds();
|
||||
assert!((890..=900).contains(&seconds));
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
let hash: String = conn.query_row(
|
||||
"SELECT code_hash FROM user_activations WHERE user_id=?1",
|
||||
[&issue.user.user_id],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
assert_eq!(hash, crate::auth::token_hash(&issue.code));
|
||||
conn.execute_batch("PRAGMA wal_checkpoint(TRUNCATE)")?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
drop(store);
|
||||
for entry in std::fs::read_dir(dir.path()).unwrap() {
|
||||
let bytes = std::fs::read(entry.unwrap().path()).unwrap();
|
||||
assert!(
|
||||
!bytes
|
||||
.windows(issue.code.len())
|
||||
.any(|window| window == issue.code.as_bytes())
|
||||
);
|
||||
}
|
||||
let reopened = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
assert!(
|
||||
reopened
|
||||
.is_user_pending_activation(&issue.user.user_id)
|
||||
.unwrap()
|
||||
);
|
||||
assert!(
|
||||
reopened
|
||||
.verify_activation_code("person", &issue.code)
|
||||
.is_ok()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activation_parallel_options_complete_once_and_parallel_duplicate_create_has_one_winner() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("server.db");
|
||||
let first = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
let second = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
let admin = hosting_actor(&first.setup_hosting_admin("admin", true).unwrap().user);
|
||||
let barrier = std::sync::Arc::new(std::sync::Barrier::new(2));
|
||||
let jobs = [first.clone(), second.clone()]
|
||||
.into_iter()
|
||||
.map(|store| {
|
||||
let barrier = barrier.clone();
|
||||
let admin = admin.clone();
|
||||
std::thread::spawn(move || {
|
||||
barrier.wait();
|
||||
store.create_provisional_user(&admin, "person")
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let results = jobs
|
||||
.into_iter()
|
||||
.map(|job| job.join().unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
|
||||
let issue = results.into_iter().find_map(|result| result.ok()).unwrap();
|
||||
let targets = [
|
||||
first.verify_activation_code("person", &issue.code).unwrap(),
|
||||
second
|
||||
.verify_activation_code("person", &issue.code)
|
||||
.unwrap(),
|
||||
];
|
||||
let jobs = [first.clone(), second]
|
||||
.into_iter()
|
||||
.zip(targets)
|
||||
.enumerate()
|
||||
.map(|(index, (store, target))| {
|
||||
let barrier = barrier.clone();
|
||||
let credential = hosting_credential(&issue.user, &format!("key-{index}"));
|
||||
std::thread::spawn(move || {
|
||||
barrier.wait();
|
||||
store.register_verified_passkey(&target, &credential)
|
||||
})
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let results = jobs
|
||||
.into_iter()
|
||||
.map(|job| job.join().unwrap())
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(results.iter().filter(|result| result.is_ok()).count(), 1);
|
||||
assert!(
|
||||
!first
|
||||
.is_user_pending_activation(&issue.user.user_id)
|
||||
.unwrap()
|
||||
);
|
||||
assert_eq!(
|
||||
first
|
||||
.list_passkey_credentials_for_user(&issue.user.user_id)
|
||||
.unwrap()
|
||||
.len(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activation_oldest_and_previous_schema_migrate_with_same_constraints_and_preserve_users() {
|
||||
fn schema(conn: &Connection) -> String {
|
||||
conn.query_row(
|
||||
"SELECT sql FROM sqlite_schema WHERE name='user_activations'",
|
||||
[],
|
||||
|r| r.get(0),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
let fresh = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
||||
let expected = fresh.with_conn(|conn| Ok(schema(conn))).unwrap();
|
||||
for baseline in [50, 99] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("server.db");
|
||||
if baseline == 50 {
|
||||
prepare_schema_v50(&path, None);
|
||||
} else {
|
||||
prepare_retained_schema(&path, baseline);
|
||||
}
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
conn.execute_batch("INSERT OR IGNORE INTO accounts VALUES('old-account','user','old','Old','1','1'); INSERT INTO users VALUES('old-user','old-account','old','Old','1','1');").unwrap();
|
||||
drop(conn);
|
||||
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
assert_eq!(
|
||||
store.get_user("old-user").unwrap().unwrap().display_name,
|
||||
"Old"
|
||||
);
|
||||
assert!(!store.is_user_pending_activation("old-user").unwrap());
|
||||
store.with_conn(|conn| {
|
||||
assert_eq!(schema(conn), expected);
|
||||
assert_eq!(current_schema_version(conn)?, 100);
|
||||
assert!(conn.execute("INSERT INTO user_activations VALUES('missing','issue','hash','future',0)", []).is_err());
|
||||
for attempts in [-1, 6] { assert!(conn.execute("INSERT INTO user_activations VALUES('old-user','issue','hash','future',?1)", [attempts]).is_err()); }
|
||||
Ok(())
|
||||
}).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activation_migration_failure_rolls_back_table_and_history() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("server.db");
|
||||
prepare_retained_schema(&path, 99);
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
conn.execute_batch("CREATE TRIGGER reject_activation_migration BEFORE INSERT ON __yoi_schema_migrations WHEN NEW.version=100 BEGIN SELECT RAISE(ABORT,'injected migration failure'); END;").unwrap();
|
||||
assert!(activation::migrate(&conn).is_err());
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 99);
|
||||
assert!(!table_exists(&conn, "user_activations").unwrap());
|
||||
conn.execute_batch("DROP TRIGGER reject_activation_migration")
|
||||
.unwrap();
|
||||
activation::migrate(&conn).unwrap();
|
||||
assert!(table_exists(&conn, "user_activations").unwrap());
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 100);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn activation_pending_workspace_owner_is_rejected_by_both_store_creation_paths() {
|
||||
let (store, admin) = activation_fixture();
|
||||
let issue = store.create_provisional_user(&admin, "person").unwrap();
|
||||
let workspace = WorkspaceRecord {
|
||||
workspace_id: "pending-workspace".into(),
|
||||
owner_account_id: issue.user.account_id.clone(),
|
||||
display_name: "Pending".into(),
|
||||
state: "active".into(),
|
||||
created_at: "1".into(),
|
||||
updated_at: "1".into(),
|
||||
};
|
||||
assert!(
|
||||
store
|
||||
.upsert_workspace(&workspace)
|
||||
.await
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("activation_required")
|
||||
);
|
||||
let signing = WorkspaceSigningIdentityActivation {
|
||||
workspace_id: workspace.workspace_id.clone(),
|
||||
key_id: "key".into(),
|
||||
public_key: "public".into(),
|
||||
public_key_fingerprint: "sha256:key".into(),
|
||||
private_material_ref: "private-ref".into(),
|
||||
provisioned_at: "1".into(),
|
||||
};
|
||||
let record = WorkspaceBootstrapRecord {
|
||||
operation_key: "operation".into(),
|
||||
request_fingerprint: "sha256:request".into(),
|
||||
workspace: workspace.clone(),
|
||||
repository: None,
|
||||
};
|
||||
assert!(
|
||||
store
|
||||
.create_workspace_bootstrap(&record, &signing, "provision")
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("activation_required")
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.get_workspace(&workspace.workspace_id)
|
||||
.await
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
let target = store.verify_activation_code("person", &issue.code).unwrap();
|
||||
store
|
||||
.register_verified_passkey(&target, &hosting_credential(&issue.user, "key"))
|
||||
.unwrap();
|
||||
store.upsert_workspace(&workspace).await.unwrap();
|
||||
assert_eq!(
|
||||
store.get_workspace(&workspace.workspace_id).await.unwrap(),
|
||||
Some(workspace)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activation_pending_removal_failure_rolls_back_inserted_credential() {
|
||||
let (store, admin) = activation_fixture();
|
||||
let issue = store.create_provisional_user(&admin, "person").unwrap();
|
||||
let target = store.verify_activation_code("person", &issue.code).unwrap();
|
||||
store.with_conn(|conn| { conn.execute_batch("CREATE TRIGGER reject_pending_removal BEFORE DELETE ON user_activations BEGIN SELECT RAISE(ABORT,'injected activation failure'); END;")?; Ok(()) }).unwrap();
|
||||
assert!(
|
||||
store
|
||||
.register_verified_passkey(&target, &hosting_credential(&issue.user, "key"))
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.is_user_pending_activation(&issue.user.user_id)
|
||||
.unwrap()
|
||||
);
|
||||
assert!(store.get_passkey_credential("key").unwrap().is_none());
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
conn.execute_batch("DROP TRIGGER reject_pending_removal")?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
store
|
||||
.register_verified_passkey(&target, &hosting_credential(&issue.user, "key"))
|
||||
.unwrap();
|
||||
assert!(
|
||||
!store
|
||||
.is_user_pending_activation(&issue.user.user_id)
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn activation_pending_device_token_is_not_delivered_even_with_injected_approval() {
|
||||
let (store, admin) = activation_fixture();
|
||||
let issue = store.create_provisional_user(&admin, "person").unwrap();
|
||||
let flow = DeviceLoginFlowRecord {
|
||||
device_code: "device".into(),
|
||||
user_code: "CODE".into(),
|
||||
verification_uri: "/device".into(),
|
||||
client_name: None,
|
||||
user_id: Some(issue.user.user_id.clone()),
|
||||
api_token_id: None,
|
||||
issued_access_token: Some("not-deliverable".into()),
|
||||
created_at: "1".into(),
|
||||
expires_at: crate::auth::rfc3339_after(chrono::Duration::minutes(5)),
|
||||
approved_at: Some("1".into()),
|
||||
consumed_at: None,
|
||||
};
|
||||
assert!(store.try_create_device_login_flow(&flow).is_err());
|
||||
store.with_conn(|conn| { conn.execute("INSERT INTO device_login_flows(device_code,user_code,verification_uri,user_id,issued_access_token,created_at,expires_at,approved_at) VALUES(?1,?2,?3,?4,?5,?6,?7,?8)", params![flow.device_code,flow.user_code,flow.verification_uri,flow.user_id,flow.issued_access_token,flow.created_at,flow.expires_at,flow.approved_at])?; Ok(()) }).unwrap();
|
||||
assert!(
|
||||
store
|
||||
.consume_device_login_token("device", "2")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
assert!(
|
||||
store
|
||||
.get_device_login_flow_by_device_code("device")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.consumed_at
|
||||
.is_none()
|
||||
);
|
||||
assert!(!store.is_hosting_admin(&issue.user.user_id).unwrap());
|
||||
}
|
||||
@@ -472,7 +472,9 @@ fn tracking_store_real_schema91_to92_retains_queue_cases_principals_audit_receip
|
||||
),
|
||||
(96,"Backend Job common notification routing".into()),
|
||||
(97, "MR notifications and authenticated implementation results".into()),
|
||||
(98, "Assignment-independent implementation reports".into())
|
||||
(98, "Assignment-independent implementation reports".into()),
|
||||
(99, "Explicit hosting administrators and self-registration policy".into()),
|
||||
(100, "Provisional users and scoped passkey activation".into())
|
||||
]
|
||||
);
|
||||
for (table, rows) in retained.iter().zip(original.iter()) {
|
||||
|
||||
@@ -54,7 +54,7 @@ async fn implementation_result_without_any_assignment_is_authenticated_and_never
|
||||
let before = store.with_conn(|conn| {
|
||||
// Retention holds are not reporting permissions.
|
||||
conn.execute("UPDATE worker_registry SET retention_state='pinned' WHERE workspace_id='w' AND worker_id='replacement'", [])?;
|
||||
assert_eq!(current_schema_version(conn)?, 98);
|
||||
assert_eq!(current_schema_version(conn)?, LATEST_SCHEMA_VERSION);
|
||||
assert_eq!(conn.query_row("SELECT count(*) FROM ticket_worker_assignments", [], |r| r.get::<_, i64>(0))?, 0);
|
||||
assert_eq!(conn.query_row("SELECT count(*) FROM pragma_table_info('ticket_implementation_results') WHERE name IN ('assignment_id','orchestrator_assignment_id')", [], |r| r.get::<_, i64>(0))?, 0);
|
||||
assert_eq!(conn.query_row("SELECT count(*) FROM sqlite_master WHERE type='trigger' AND name='implementation_delegation_capture'", [], |r| r.get::<_, i64>(0))?, 0);
|
||||
@@ -372,7 +372,7 @@ async fn implementation_result_v97_to_v98_preserves_legacy_bytes_receipts_and_no
|
||||
let (dir, store) = tracking_store_fixture().await;
|
||||
tracking_store_assign(&store, "delegation", "assign");
|
||||
let worker = routing_worker_assign(&store, "implementation").worker;
|
||||
// Reconstruct schema97 from a fresh schema98 fixture, including its historical capture.
|
||||
// Reconstruct schema97 from a fresh current-schema fixture, including its historical capture.
|
||||
store.with_conn(|conn| {
|
||||
conn.execute_batch("DROP TRIGGER notification_mr_event;
|
||||
DROP TRIGGER notification_mr_source;
|
||||
@@ -381,6 +381,10 @@ async fn implementation_result_v97_to_v98_preserves_legacy_bytes_receipts_and_no
|
||||
DROP TABLE ticket_implementation_results;
|
||||
DROP TABLE ticket_implementation_delegations;
|
||||
DROP TABLE merge_request_source_observations;
|
||||
DROP TABLE user_activations;
|
||||
UPDATE __yoi_schema_migrations SET version=99 WHERE version=100;
|
||||
DROP TABLE hosting_administrators; DROP TABLE hosting_settings;
|
||||
UPDATE __yoi_schema_migrations SET version=98 WHERE version=99;
|
||||
UPDATE __yoi_schema_migrations SET version=96 WHERE version=98;")?;
|
||||
migrate_business_notifications_v96_to_v97(conn)?;
|
||||
assert_eq!(current_schema_version(conn)?, 97);
|
||||
@@ -509,6 +513,10 @@ async fn mr_notification_upgrade_recovers_events_not_comments_and_retains_unknow
|
||||
// Reconstruct the prior composition before its new business capture existed.
|
||||
conn.execute_batch("DROP TRIGGER notification_mr_event; DROP TRIGGER notification_mr_source; DROP TRIGGER notification_implementation_result; DROP TRIGGER IF EXISTS implementation_delegation_capture;
|
||||
DROP TABLE ticket_implementation_results; DROP TABLE ticket_implementation_delegations; DROP TABLE merge_request_source_observations;
|
||||
DROP TABLE user_activations;
|
||||
UPDATE __yoi_schema_migrations SET version=99 WHERE version=100;
|
||||
DROP TABLE hosting_administrators; DROP TABLE hosting_settings;
|
||||
UPDATE __yoi_schema_migrations SET version=98 WHERE version=99;
|
||||
UPDATE __yoi_schema_migrations SET version=96 WHERE version=98;")?;
|
||||
conn.execute_batch("INSERT INTO merge_requests VALUES('w','historical-mr','main','open','refs/heads/work','refs/heads/main','3','3');
|
||||
INSERT INTO merge_request_ticket_relations VALUES('w','historical-mr','tracking','implements','3');
|
||||
@@ -663,7 +671,11 @@ async fn mr_event_actors_preserve_self_suppression_in_capture_and_backfill() {
|
||||
if historical {
|
||||
conn.execute_batch("DROP TRIGGER notification_mr_event; DROP TRIGGER notification_mr_source; DROP TRIGGER notification_implementation_result; DROP TRIGGER IF EXISTS implementation_delegation_capture;
|
||||
DROP TABLE ticket_implementation_results; DROP TABLE ticket_implementation_delegations; DROP TABLE merge_request_source_observations;
|
||||
UPDATE __yoi_schema_migrations SET version=96 WHERE version=98;")?;
|
||||
DROP TABLE user_activations;
|
||||
UPDATE __yoi_schema_migrations SET version=99 WHERE version=100;
|
||||
DROP TABLE hosting_administrators; DROP TABLE hosting_settings;
|
||||
UPDATE __yoi_schema_migrations SET version=98 WHERE version=99;
|
||||
UPDATE __yoi_schema_migrations SET version=96 WHERE version=98;")?;
|
||||
}
|
||||
conn.execute_batch("INSERT INTO merge_requests VALUES('w','actor-mr','main','open','work','main','3','3');
|
||||
INSERT INTO merge_request_ticket_relations VALUES('w','actor-mr','tracking','implements','3');")?;
|
||||
|
||||
@@ -12,6 +12,15 @@ DROP TABLE typed_ticket_events;
|
||||
DROP TABLE typed_tickets;
|
||||
DROP TABLE workspace_resource_key_counters;
|
||||
DROP TABLE workspace_resource_keys;
|
||||
CREATE TABLE hosting_administrators (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE hosting_settings (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
self_registration_enabled INTEGER NOT NULL DEFAULT 0 CHECK (self_registration_enabled IN (0, 1))
|
||||
);
|
||||
INSERT INTO hosting_settings(singleton, self_registration_enabled) VALUES(1, 0);
|
||||
CREATE TABLE accounts (
|
||||
account_id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL CHECK (kind IN ('user', 'organization')),
|
||||
|
||||
@@ -22,6 +22,7 @@ enum Command {
|
||||
Serve(ServeOptions),
|
||||
Migrate(MigrateOptions),
|
||||
BootstrapAuth(BootstrapAuthOptions),
|
||||
HostingAdmin(HostingAdminOptions),
|
||||
Skills(SkillsCommand),
|
||||
Help,
|
||||
}
|
||||
@@ -46,6 +47,13 @@ struct BootstrapAuthOptions {
|
||||
label: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct HostingAdminOptions {
|
||||
handle: String,
|
||||
create_user: bool,
|
||||
database: Option<PathBuf>,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
struct SkillWorkspaceOptions {
|
||||
workspace_id: String,
|
||||
@@ -86,6 +94,7 @@ async fn run() -> Result<(), Box<dyn std::error::Error>> {
|
||||
Command::Serve(options) => run_serve(options).await,
|
||||
Command::Migrate(options) => run_migrate(options),
|
||||
Command::BootstrapAuth(options) => run_bootstrap_auth(options),
|
||||
Command::HostingAdmin(options) => run_hosting_admin(options),
|
||||
Command::Skills(command) => run_skills(command),
|
||||
Command::Help => Ok(()),
|
||||
}
|
||||
@@ -100,6 +109,13 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
|
||||
match command.as_str() {
|
||||
"migrate" => parse_migrate_options(rest).map(Command::Migrate),
|
||||
"bootstrap-auth" => parse_bootstrap_auth_options(rest).map(Command::BootstrapAuth),
|
||||
"hosting-admin" => {
|
||||
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
|
||||
print_hosting_admin_help();
|
||||
return Ok(Command::Help);
|
||||
}
|
||||
parse_hosting_admin_options(rest).map(Command::HostingAdmin)
|
||||
}
|
||||
"skills" => parse_skills_command(rest),
|
||||
"serve" => {
|
||||
if rest.iter().any(|arg| arg == "--help" || arg == "-h") {
|
||||
@@ -113,7 +129,7 @@ fn parse_command(args: &[String]) -> Result<Command, CliError> {
|
||||
Ok(Command::Help)
|
||||
}
|
||||
other => Err(CliError(format!(
|
||||
"unknown command `{other}`; expected `bootstrap-auth`, `migrate`, `skills`, or `serve`"
|
||||
"unknown command `{other}`; expected `hosting-admin`, `bootstrap-auth`, `migrate`, `skills`, or `serve`"
|
||||
))),
|
||||
}
|
||||
}
|
||||
@@ -224,6 +240,85 @@ fn run_migrate(options: MigrateOptions) -> Result<(), Box<dyn std::error::Error>
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn run_hosting_admin(options: HostingAdminOptions) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let database_path = options
|
||||
.database
|
||||
.unwrap_or_else(ServerConfig::default_server_database_path);
|
||||
if let Some(parent) = database_path
|
||||
.parent()
|
||||
.filter(|parent| !parent.as_os_str().is_empty())
|
||||
{
|
||||
std::fs::create_dir_all(parent)?;
|
||||
}
|
||||
let store = SqliteWorkspaceStore::open(&database_path)?;
|
||||
let setup = store.setup_hosting_admin(&options.handle, options.create_user)?;
|
||||
let mut output = serde_json::json!({
|
||||
"user": {
|
||||
"user_id": setup.user.user_id,
|
||||
"account_id": setup.user.account_id,
|
||||
"handle": setup.user.handle,
|
||||
"display_name": setup.user.display_name,
|
||||
},
|
||||
"hosting_admin": true,
|
||||
});
|
||||
if let Some(bearer) = setup.initial_bearer {
|
||||
output["access_token"] = bearer.access_token.into();
|
||||
output["expires_at"] = bearer.expires_at.into();
|
||||
output["token_type"] = "bearer".into();
|
||||
}
|
||||
// Explicit trusted host setup is the only place this secret is printed.
|
||||
println!("{}", serde_json::to_string(&output)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn parse_hosting_admin_options(args: &[String]) -> Result<HostingAdminOptions, CliError> {
|
||||
let mut handle = None;
|
||||
let mut database = None;
|
||||
let mut create_user = false;
|
||||
let mut index = 0;
|
||||
while index < args.len() {
|
||||
let arg = &args[index];
|
||||
if arg == "--create-user" {
|
||||
if create_user {
|
||||
return Err(CliError("--create-user must be provided only once".into()));
|
||||
}
|
||||
create_user = true;
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
let (name, inline) = arg
|
||||
.split_once('=')
|
||||
.map_or((arg.as_str(), None), |(name, value)| (name, Some(value)));
|
||||
let target = match name {
|
||||
"--handle" => &mut handle,
|
||||
"--database" => &mut database,
|
||||
other => return Err(CliError(format!("unknown hosting-admin option {other}"))),
|
||||
};
|
||||
let value = match inline {
|
||||
Some(value) => value.to_string(),
|
||||
None => {
|
||||
index += 1;
|
||||
args.get(index)
|
||||
.filter(|value| !value.starts_with("--"))
|
||||
.ok_or_else(|| CliError(format!("{name} requires a value")))?
|
||||
.clone()
|
||||
}
|
||||
};
|
||||
if value.is_empty() {
|
||||
return Err(CliError(format!("{name} requires a value")));
|
||||
}
|
||||
if target.replace(value).is_some() {
|
||||
return Err(CliError(format!("{name} must be provided only once")));
|
||||
}
|
||||
index += 1;
|
||||
}
|
||||
Ok(HostingAdminOptions {
|
||||
handle: handle.ok_or_else(|| CliError("hosting-admin requires --handle".into()))?,
|
||||
create_user,
|
||||
database: database.map(PathBuf::from),
|
||||
})
|
||||
}
|
||||
|
||||
fn run_bootstrap_auth(options: BootstrapAuthOptions) -> Result<(), Box<dyn std::error::Error>> {
|
||||
let database_path = ServerConfig::default_server_database_path();
|
||||
if let Some(parent) = database_path.parent() {
|
||||
@@ -643,7 +738,13 @@ fn parse_listen(value: &str) -> Result<SocketAddr, CliError> {
|
||||
|
||||
fn print_help() {
|
||||
println!(
|
||||
"yoi-server\n\nUsage:\n yoi-server bootstrap-auth --handle <HANDLE> [--display-name <NAME>] [--label <LABEL>]\n yoi-server migrate [--dry-run] [--database <PATH>]\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
|
||||
"yoi-server\n\nUsage:\n yoi-server hosting-admin --handle <HANDLE> [--create-user] [--database <PATH>]\n yoi-server bootstrap-auth --handle <HANDLE> [--display-name <NAME>] [--label <LABEL>]\n yoi-server migrate [--dry-run] [--database <PATH>]\n yoi-server skills <COMMAND> [OPTIONS]\n yoi-server serve [OPTIONS]\n\nOptions:\n -h, --help Print help"
|
||||
);
|
||||
}
|
||||
|
||||
fn print_hosting_admin_help() {
|
||||
println!(
|
||||
"yoi-server hosting-admin\n\nUsage:\n yoi-server hosting-admin --handle <HANDLE> [--create-user] [--database <PATH>]\n\nDescription:\n Trusted host-only explicit administrator setup. By default selects an existing known user and grants hosting authority without issuing credentials. --create-user requires a new handle and atomically creates the user, grant and initial bearer (15-minute expiry), printed once to private stdout for passkey addition. Open /hosting/setup on this Server's trusted HTTPS origin (or localhost), enter the token, add a passkey, then explicitly sign in with that key before opening /hosting/settings. Repeating creation fails, never refreshing credentials. Neither mode enables public self-registration. Existing Workspace permissions are unchanged.\n\nOptions:\n --handle <HANDLE> Known user handle, or new handle with --create-user\n --create-user Explicitly create a new user and print its initial bearer\n --database <PATH> Server DB path (default: canonical Yoi server DB)\n -h, --help Print help"
|
||||
);
|
||||
}
|
||||
|
||||
@@ -702,6 +803,51 @@ mod tests {
|
||||
assert_eq!(error.to_string(), "bootstrap-auth requires --handle");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosting_admin_cli_requires_explicit_creation_and_selects_database() {
|
||||
let args = |values: &[&str]| {
|
||||
values
|
||||
.iter()
|
||||
.map(|value| value.to_string())
|
||||
.collect::<Vec<_>>()
|
||||
};
|
||||
let Command::HostingAdmin(existing) =
|
||||
parse_command(&args(&["hosting-admin", "--handle", "known-user"])).unwrap()
|
||||
else {
|
||||
panic!("expected hosting admin")
|
||||
};
|
||||
assert!(!existing.create_user);
|
||||
assert!(existing.database.is_none());
|
||||
let Command::HostingAdmin(new) = parse_command(&args(&[
|
||||
"hosting-admin",
|
||||
"--handle=new-admin",
|
||||
"--create-user",
|
||||
"--database=/tmp/isolated.db",
|
||||
]))
|
||||
.unwrap() else {
|
||||
panic!("expected hosting admin")
|
||||
};
|
||||
assert!(new.create_user);
|
||||
assert_eq!(new.handle, "new-admin");
|
||||
assert_eq!(new.database, Some(PathBuf::from("/tmp/isolated.db")));
|
||||
for invalid in [
|
||||
vec![],
|
||||
vec!["--create-user"],
|
||||
vec!["--handle"],
|
||||
vec!["--handle="],
|
||||
vec!["--handle", "a", "--handle=b"],
|
||||
vec!["--handle", "a", "--database"],
|
||||
vec!["--handle", "a", "--database=first", "--database=second"],
|
||||
vec!["--handle", "a", "--create-user", "--create-user"],
|
||||
vec!["--handle", "a", "--token", "secret"],
|
||||
] {
|
||||
assert!(
|
||||
parse_hosting_admin_options(&args(&invalid)).is_err(),
|
||||
"{invalid:?}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn parse_migrate_uses_the_shared_schema_path() {
|
||||
let command = parse_command(&[
|
||||
@@ -832,7 +978,7 @@ mod tests {
|
||||
assert_eq!(
|
||||
error.to_string(),
|
||||
format!(
|
||||
"unknown command `{command}`; expected `bootstrap-auth`, `migrate`, `skills`, or `serve`"
|
||||
"unknown command `{command}`; expected `hosting-admin`, `bootstrap-auth`, `migrate`, `skills`, or `serve`"
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -506,7 +506,7 @@ impl Drop for AbortServer {
|
||||
async fn drive_generated_client_roundtrips_binary_and_declared_headers_over_real_http() {
|
||||
let temp = tempfile::tempdir().unwrap();
|
||||
let api = test_api(temp.path()).await;
|
||||
seed_test_api_token(api.store.as_ref(), "drive-client");
|
||||
seed_test_workspace_owner_token(api.store.as_ref(), "drive-client").await;
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let url = format!("http://{}", listener.local_addr().unwrap());
|
||||
let app = build_router(api);
|
||||
|
||||
@@ -32,6 +32,7 @@ struct RealDriveFixture {
|
||||
api: WorkspaceApi,
|
||||
owner: String,
|
||||
second: String,
|
||||
foreign: String,
|
||||
_temp: tempfile::TempDir,
|
||||
}
|
||||
impl RealDriveFixture {
|
||||
@@ -41,7 +42,7 @@ impl RealDriveFixture {
|
||||
config.repositories.clear();
|
||||
let store = Arc::new(SqliteWorkspaceStore::open_for_test(&config.database_path).unwrap());
|
||||
let owner = seed_test_api_token(store.as_ref(), "drive-web-owner");
|
||||
let second = seed_test_api_token(store.as_ref(), "drive-web-second");
|
||||
let foreign = seed_test_api_token(store.as_ref(), "drive-web-foreign");
|
||||
let server_api = WorkspaceServerApi::new_for_test(config, store.clone());
|
||||
let server = LoopbackServer::start(workspace_server_router(server_api.clone())).await;
|
||||
// Exercise T-717's real repositoryless Workspace bootstrap, not a
|
||||
@@ -56,6 +57,25 @@ impl RealDriveFixture {
|
||||
let created = http_json(response, StatusCode::CREATED).await;
|
||||
assert!(created["repository"].is_null(), "{created}");
|
||||
let workspace_id = created["workspace"]["workspace_id"].as_str().unwrap();
|
||||
// Both concurrent clients need current owner authority. A foreign
|
||||
// Account must be rejected by admission before it can reach Drive CAS.
|
||||
let second = "api-token-drive-web-second".to_string();
|
||||
store
|
||||
.create_api_token(&ApiTokenRecord {
|
||||
token_hash: crate::auth::token_hash(&second),
|
||||
token_id: "drive-web-second-token".into(),
|
||||
user_id: store
|
||||
.get_user_by_handle("user-drive-web-owner")
|
||||
.unwrap()
|
||||
.unwrap()
|
||||
.user_id,
|
||||
label: "second owner client".into(),
|
||||
created_at: "2026-01-01T00:00:00Z".into(),
|
||||
expires_at: None,
|
||||
last_used_at: None,
|
||||
revoked_at: None,
|
||||
})
|
||||
.unwrap();
|
||||
assert!(store.list_repositories(workspace_id).unwrap().is_empty());
|
||||
let api = server_api
|
||||
.api_for_workspace(workspace_id)
|
||||
@@ -76,6 +96,7 @@ impl RealDriveFixture {
|
||||
api,
|
||||
owner,
|
||||
second,
|
||||
foreign,
|
||||
_temp: temp,
|
||||
}
|
||||
}
|
||||
@@ -405,7 +426,7 @@ async fn repositoryless_drive_real_web_adapter_preserves_bytes_cas_and_latest_re
|
||||
.expect("Deno must be installed for this explicit process test");
|
||||
let wire = serde_json::to_vec(&json!({
|
||||
"origin":fixture.server.origin,"workspace_id":fixture.api.workspace_id(),
|
||||
"owner_token":fixture.owner,"second_token":fixture.second,"worker_entry":worker_entry,
|
||||
"owner_token":fixture.owner,"second_token":fixture.second,"foreign_token":fixture.foreign,"worker_entry":worker_entry,
|
||||
}))
|
||||
.unwrap();
|
||||
let mut stdin = child.stdin.take().unwrap();
|
||||
@@ -422,6 +443,7 @@ async fn repositoryless_drive_real_web_adapter_preserves_bytes_cas_and_latest_re
|
||||
String::from_utf8_lossy(bytes)
|
||||
.replace(&fixture.owner, "[redacted]")
|
||||
.replace(&fixture.second, "[redacted]")
|
||||
.replace(&fixture.foreign, "[redacted]")
|
||||
};
|
||||
assert!(
|
||||
output.status.success(),
|
||||
|
||||
@@ -0,0 +1,318 @@
|
||||
// Production generated routes, SQLite and WebAuthn verifier share the passkey
|
||||
// security fixture. Only the authenticator is SoftPasskey, not a browser/OS key.
|
||||
// The ordinary Server test target selects these isolated integration cases.
|
||||
|
||||
impl Fixture {
|
||||
async fn activation_admin(&self) -> String {
|
||||
let (cookie, user) = self
|
||||
.enroll(
|
||||
"registration",
|
||||
None,
|
||||
"activation-admin",
|
||||
&mut authenticator(),
|
||||
)
|
||||
.await;
|
||||
self.api
|
||||
.config_store
|
||||
.setup_hosting_admin(&user.handle, false)
|
||||
.unwrap();
|
||||
let actor = RequestActor {
|
||||
user_id: user.user_id,
|
||||
account_id: user.account_id,
|
||||
handle: user.handle,
|
||||
display_name: user.display_name,
|
||||
auth_method: ActorAuthMethod::BrowserSession,
|
||||
};
|
||||
self.api
|
||||
.store
|
||||
.set_hosting_self_registration_enabled(&actor, false)
|
||||
.unwrap();
|
||||
cookie
|
||||
}
|
||||
|
||||
async fn issue_activation(
|
||||
&self,
|
||||
admin: &str,
|
||||
handle: &str,
|
||||
) -> server_api::ActivationCodeResponse {
|
||||
let (status, headers, body) = self
|
||||
.post(
|
||||
"/api/hosting/accounts",
|
||||
Some(admin),
|
||||
Some(ORIGIN_URL),
|
||||
serde_json::json!({"handle":handle}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(
|
||||
status,
|
||||
StatusCode::OK,
|
||||
"code issuance must succeed without printing its response"
|
||||
);
|
||||
assert_eq!(headers[CACHE_CONTROL], "no-store");
|
||||
assert!(!headers.contains_key(axum::http::header::SET_COOKIE));
|
||||
serde_json::from_value(body).unwrap()
|
||||
}
|
||||
|
||||
async fn activation_options(
|
||||
&self,
|
||||
issued: &server_api::ActivationCodeResponse,
|
||||
) -> PasskeyRegistrationOptionsResponse {
|
||||
let (status, headers, body) = self
|
||||
.post(
|
||||
"/api/auth/activation/options",
|
||||
None,
|
||||
Some(ORIGIN_URL),
|
||||
serde_json::json!({"handle": issued.user.handle, "code": issued.code}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(headers[CACHE_CONTROL], "no-store");
|
||||
assert!(!headers.contains_key(axum::http::header::SET_COOKIE));
|
||||
assert!(body.get("access_token").is_none());
|
||||
serde_json::from_value(body).unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn activation_requires_verified_passkey_and_never_grants_hosting_authority() {
|
||||
let f = Fixture::new().await;
|
||||
let admin = f.activation_admin().await;
|
||||
let issued = f.issue_activation(&admin, "pending-person").await;
|
||||
assert!(!f.api.store.hosting_self_registration_enabled().unwrap());
|
||||
let abandoned = f.activation_options(&issued).await;
|
||||
assert!(
|
||||
f.api
|
||||
.store
|
||||
.is_user_pending_activation(&issued.user.user_id)
|
||||
.unwrap()
|
||||
);
|
||||
assert!(f.credentials(&issued.user).is_empty());
|
||||
rejected(
|
||||
f.post(
|
||||
"/api/auth/passkeys/login/options",
|
||||
None,
|
||||
None,
|
||||
serde_json::json!({"handle":issued.user.handle}),
|
||||
)
|
||||
.await,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"passkey",
|
||||
);
|
||||
// A valid attestation for a different challenge cannot activate this identity.
|
||||
let options = f.activation_options(&issued).await;
|
||||
let mut tampered = Fixture::credential(abandoned, &mut authenticator());
|
||||
tampered["challenge_id"] = Value::String(options.challenge_id);
|
||||
rejected(
|
||||
f.post("/api/auth/activation/complete", None, None, tampered)
|
||||
.await,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"webauthn_registration_verification_failed",
|
||||
);
|
||||
assert!(
|
||||
f.api
|
||||
.store
|
||||
.is_user_pending_activation(&issued.user.user_id)
|
||||
.unwrap()
|
||||
);
|
||||
assert!(f.credentials(&issued.user).is_empty());
|
||||
// Cancellation/failure did not spend the activation code. Fresh options resume.
|
||||
let options = f.activation_options(&issued).await;
|
||||
let mut key = authenticator();
|
||||
let complete = Fixture::credential(options, &mut key);
|
||||
let (status, headers, body) = f
|
||||
.post(
|
||||
"/api/auth/activation/complete",
|
||||
None,
|
||||
None,
|
||||
complete.clone(),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert!(headers.contains_key(axum::http::header::SET_COOKIE));
|
||||
assert_eq!(body["user"]["user_id"], issued.user.user_id);
|
||||
assert!(
|
||||
!f.api
|
||||
.store
|
||||
.is_user_pending_activation(&issued.user.user_id)
|
||||
.unwrap()
|
||||
);
|
||||
assert!(!f.api.store.is_hosting_admin(&issued.user.user_id).unwrap());
|
||||
assert_eq!(f.credentials(&issued.user).len(), 1);
|
||||
rejected(
|
||||
f.post("/api/auth/activation/complete", None, None, complete)
|
||||
.await,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"invalid_passkey_challenge",
|
||||
);
|
||||
let (status, headers, _) = f
|
||||
.post(
|
||||
"/api/auth/activation/options",
|
||||
None,
|
||||
None,
|
||||
serde_json::json!({"handle":issued.user.handle,"code":issued.code}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert!(!headers.contains_key(axum::http::header::SET_COOKIE));
|
||||
assert_eq!(f.login("pending-person", &mut key).await, issued.user);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn activation_ceremonies_cannot_be_used_by_registration_or_addition_endpoints() {
|
||||
let f = Fixture::new().await;
|
||||
let admin = f.activation_admin().await;
|
||||
let issued = f.issue_activation(&admin, "purpose-bound").await;
|
||||
for endpoint in [
|
||||
"/api/auth/passkeys/registration/complete",
|
||||
"/api/auth/passkeys/addition/complete",
|
||||
] {
|
||||
let options = f.activation_options(&issued).await;
|
||||
let complete = Fixture::credential(options, &mut authenticator());
|
||||
let (status, _, _) = f
|
||||
.post(endpoint, Some(&admin), Some(ORIGIN_URL), complete)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert!(f.credentials(&issued.user).is_empty());
|
||||
assert!(
|
||||
f.api
|
||||
.store
|
||||
.is_user_pending_activation(&issued.user.user_id)
|
||||
.unwrap()
|
||||
);
|
||||
}
|
||||
// The converse also fails: even a logged-in administrator's addition ceremony
|
||||
// must not become an activation or create credentials for the provisional user.
|
||||
let options = f.options("addition", Some(&admin), "ignored").await;
|
||||
let complete = Fixture::credential(options, &mut authenticator());
|
||||
rejected(
|
||||
f.post(
|
||||
"/api/auth/activation/complete",
|
||||
Some(&admin),
|
||||
Some(ORIGIN_URL),
|
||||
complete,
|
||||
)
|
||||
.await,
|
||||
StatusCode::BAD_REQUEST,
|
||||
"passkey_registration_binding_mismatch",
|
||||
);
|
||||
assert!(f.credentials(&issued.user).is_empty());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn activation_reissue_invalidates_even_a_previously_verified_code_ceremony() {
|
||||
let f = Fixture::new().await;
|
||||
let admin = f.activation_admin().await;
|
||||
let issued = f.issue_activation(&admin, "rotated").await;
|
||||
let options = f.activation_options(&issued).await;
|
||||
let complete = Fixture::credential(options, &mut authenticator());
|
||||
let (status, headers, body) = f
|
||||
.post(
|
||||
"/api/hosting/accounts/activation-code",
|
||||
Some(&admin),
|
||||
Some(ORIGIN_URL),
|
||||
serde_json::json!({"handle":"rotated"}),
|
||||
)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::OK);
|
||||
assert_eq!(headers[CACHE_CONTROL], "no-store");
|
||||
let replacement: server_api::ActivationCodeResponse = serde_json::from_value(body).unwrap();
|
||||
assert!(
|
||||
issued.code != replacement.code,
|
||||
"reissue must replace the secret"
|
||||
);
|
||||
let (status, _, _) = f
|
||||
.post("/api/auth/activation/complete", None, None, complete)
|
||||
.await;
|
||||
assert_eq!(status, StatusCode::BAD_REQUEST);
|
||||
assert!(f.credentials(&issued.user).is_empty());
|
||||
assert!(
|
||||
f.api
|
||||
.store
|
||||
.is_user_pending_activation(&issued.user.user_id)
|
||||
.unwrap()
|
||||
);
|
||||
let _ = f.activation_options(&replacement).await;
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn activation_two_valid_ceremonies_complete_once_and_save_no_extra_credential() {
|
||||
let f = Fixture::new().await;
|
||||
let admin = f.activation_admin().await;
|
||||
let issued = f.issue_activation(&admin, "one-winner").await;
|
||||
let first = Fixture::credential(f.activation_options(&issued).await, &mut authenticator());
|
||||
let second = Fixture::credential(f.activation_options(&issued).await, &mut authenticator());
|
||||
let (a, b) = tokio::join!(
|
||||
f.post("/api/auth/activation/complete", None, None, first),
|
||||
f.post("/api/auth/activation/complete", None, None, second)
|
||||
);
|
||||
assert_eq!(
|
||||
[a.0, b.0]
|
||||
.iter()
|
||||
.filter(|status| **status == StatusCode::OK)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(
|
||||
[a.0, b.0]
|
||||
.iter()
|
||||
.filter(|status| **status == StatusCode::BAD_REQUEST)
|
||||
.count(),
|
||||
1
|
||||
);
|
||||
assert_eq!(f.credentials(&issued.user).len(), 1);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn activation_direct_admin_mutations_reject_foreign_origin_even_with_live_bearer() {
|
||||
use api_macros::HttpError;
|
||||
use server_api::ServerApi;
|
||||
let f = Fixture::new().await;
|
||||
let cookie = f.activation_admin().await;
|
||||
let token = f.device_token(&cookie).await;
|
||||
let service = ServerApiContractService::Workspace(f.api.clone());
|
||||
let context = |origin: Option<&str>| server_api::ServerRequestContext {
|
||||
actor: None,
|
||||
worker_source: None,
|
||||
runtime_source: None,
|
||||
// A forged extension cannot override the byte-exact transport Origin.
|
||||
origin: Some(ORIGIN_URL.into()),
|
||||
transport_headers: std::iter::once((
|
||||
"authorization".into(),
|
||||
format!("Bearer {token}").into_bytes(),
|
||||
))
|
||||
.chain(origin.map(|value| ("origin".into(), value.as_bytes().to_vec())))
|
||||
.collect(),
|
||||
};
|
||||
for handle in ["new-id", "existing-pending"] {
|
||||
let request = || server_api::ProvisionalAccountRequest {
|
||||
handle: handle.into(),
|
||||
};
|
||||
assert_eq!(
|
||||
service
|
||||
.hosting_account_create(context(Some("https://foreign.test")), request())
|
||||
.await
|
||||
.unwrap_err()
|
||||
.status_code(),
|
||||
403
|
||||
);
|
||||
assert_eq!(
|
||||
service
|
||||
.hosting_activation_code_reissue(context(Some("https://foreign.test")), request())
|
||||
.await
|
||||
.unwrap_err()
|
||||
.status_code(),
|
||||
403
|
||||
);
|
||||
assert!(f.api.store.get_user_by_handle(handle).unwrap().is_none());
|
||||
}
|
||||
let issued = service
|
||||
.hosting_account_create(
|
||||
context(None),
|
||||
server_api::ProvisionalAccountRequest {
|
||||
handle: "non-browser-bearer".into(),
|
||||
},
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(issued.user.handle, "non-browser-bearer");
|
||||
}
|
||||
@@ -195,7 +195,8 @@ async fn unfinished_dependencies_allow_manual_and_orchestrator_resource_free_sta
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let token = seed_test_api_token(api.store.as_ref(), "partial-start-owner");
|
||||
let token =
|
||||
seed_test_workspace_owner_token(api.store.as_ref(), "partial-start-owner").await;
|
||||
let ticket = browser_ticket_backend(&api)
|
||||
.unwrap()
|
||||
.create(ticket::NewTicket::new(
|
||||
@@ -349,7 +350,8 @@ async fn unfinished_dependencies_allow_read_only_start_and_assign_without_write_
|
||||
assign_test_orchestrator(api, &fixture.ticket_id);
|
||||
}
|
||||
let delegation = observed_start_delegation(api, &fixture.ticket_id, source.as_ref());
|
||||
let token = seed_test_api_token(api.store.as_ref(), "read-only-start-owner");
|
||||
let token =
|
||||
seed_test_workspace_owner_token(api.store.as_ref(), "read-only-start-owner").await;
|
||||
let worker = if existing {
|
||||
// Drop the unrelated checkout so the entire attachment set is reference-only.
|
||||
let main = api
|
||||
@@ -482,7 +484,7 @@ async fn unfinished_dependencies_do_not_bypass_http_auth_closed_or_resource_bind
|
||||
let fixture = manual_worker_assignment_fixture().await;
|
||||
let api = &fixture.api;
|
||||
add_unfinished_start_dependency(api, &fixture.ticket_id);
|
||||
let token = seed_test_api_token(api.store.as_ref(), "guard-start-owner");
|
||||
let token = seed_test_workspace_owner_token(api.store.as_ref(), "guard-start-owner").await;
|
||||
let path = format!(
|
||||
"/api/w/{TEST_WORKSPACE_ID}/tickets/{}/assignments/worker",
|
||||
fixture.ticket_id
|
||||
@@ -544,7 +546,8 @@ async fn orchestrator_start_and_assign_reject_missing_stale_cleared_or_reissued_
|
||||
let runtime = WorkdirlessFixtureRuntime::default();
|
||||
api.runtime.register_or_replace(runtime.clone());
|
||||
let source = start_source(&mut api, &runtime).await;
|
||||
let token = seed_test_api_token(api.store.as_ref(), "delegation-owner");
|
||||
let token =
|
||||
seed_test_workspace_owner_token(api.store.as_ref(), "delegation-owner").await;
|
||||
let ticket = browser_ticket_backend(&api)
|
||||
.unwrap()
|
||||
.create(ticket::NewTicket::new("Observed delegation start"))
|
||||
@@ -665,7 +668,8 @@ async fn replaced_orchestrator_cannot_start_or_assign_under_retained_current_del
|
||||
let runtime = WorkdirlessFixtureRuntime::default();
|
||||
api.runtime.register_or_replace(runtime.clone());
|
||||
let source = start_source(&mut api, &runtime).await;
|
||||
let token = seed_test_api_token(api.store.as_ref(), "obsolete-worker-owner");
|
||||
let token =
|
||||
seed_test_workspace_owner_token(api.store.as_ref(), "obsolete-worker-owner").await;
|
||||
let ticket = browser_ticket_backend(&api)
|
||||
.unwrap()
|
||||
.create(ticket::NewTicket::new(
|
||||
|
||||
@@ -5,7 +5,8 @@ async fn runtime_and_account_assign_retries_return_saved_result_after_clear_and_
|
||||
for runtime_source in [true, false] {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let (api, _, identity, source, ticket) = tracking_fixture(directory.path()).await;
|
||||
let token = seed_test_api_token(api.store.as_ref(), "assign-replay-owner");
|
||||
let token =
|
||||
seed_test_workspace_owner_token(api.store.as_ref(), "assign-replay-owner").await;
|
||||
let request = |method: &str, path: &str, body: Vec<u8>| {
|
||||
if runtime_source {
|
||||
runtime_source_request(&identity, Some(&source.worker_id), method, path, body)
|
||||
@@ -370,7 +371,8 @@ async fn authenticated_account_assign_and_clear_retries_preserve_source_and_reje
|
||||
{
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let (api, _, _, _, ticket) = tracking_fixture(dir.path()).await;
|
||||
let owner = seed_test_api_token(api.store.as_ref(), "assignment-account-owner");
|
||||
let owner =
|
||||
seed_test_workspace_owner_token(api.store.as_ref(), "assignment-account-owner").await;
|
||||
let other = seed_test_api_token(api.store.as_ref(), "assignment-account-other");
|
||||
let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator");
|
||||
let body = serde_json::to_vec(&serde_json::json!({"operation_id":"account-assign", "principal":{"kind":"workspace_agent","agent_key":"workspace-orchestrator"}})).unwrap();
|
||||
@@ -393,7 +395,7 @@ async fn authenticated_account_assign_and_clear_retries_preserve_source_and_reje
|
||||
let actor = format!(
|
||||
"workspace-user:{}",
|
||||
serde_json::to_string(&(
|
||||
"account-assignment-account-owner",
|
||||
&format!("account-{TEST_WORKSPACE_ID}"),
|
||||
"user-assignment-account-owner"
|
||||
))
|
||||
.unwrap()
|
||||
@@ -411,7 +413,7 @@ async fn authenticated_account_assign_and_clear_retries_preserve_source_and_reje
|
||||
tracking_response(
|
||||
app.clone(),
|
||||
request(&other, "PUT", &path, body),
|
||||
StatusCode::CONFLICT,
|
||||
StatusCode::FORBIDDEN,
|
||||
)
|
||||
.await;
|
||||
let assignment_id = assigned["assignment"]["assignment_id"].as_str().unwrap();
|
||||
@@ -434,7 +436,7 @@ async fn authenticated_account_assign_and_clear_retries_preserve_source_and_reje
|
||||
tracking_response(
|
||||
app,
|
||||
request(&other, "DELETE", &clear_path, Vec::new()),
|
||||
StatusCode::CONFLICT,
|
||||
StatusCode::FORBIDDEN,
|
||||
)
|
||||
.await;
|
||||
let released = api
|
||||
@@ -1105,7 +1107,7 @@ async fn assignment_retry_is_idempotent_but_another_authenticated_actor_cannot_r
|
||||
let current = assign_tracking(&api, &identity, &source, &ticket).await;
|
||||
let replay = assign_tracking(&api, &identity, &source, &ticket).await;
|
||||
assert_eq!(replay, current);
|
||||
let token = seed_test_api_token(api.store.as_ref(), "assignment-replay-user");
|
||||
let token = seed_test_workspace_owner_token(api.store.as_ref(), "assignment-replay-user").await;
|
||||
let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignments/orchestrator");
|
||||
let body = serde_json::to_vec(&serde_json::json!({"operation_id":"assign-tracking", "principal":{"kind":"workspace_agent", "agent_key":"workspace-orchestrator"}})).unwrap();
|
||||
tracking_response(
|
||||
@@ -1226,7 +1228,8 @@ async fn tracking_model_authorship_and_role_fields_are_rejected_and_human_is_not
|
||||
)
|
||||
.await;
|
||||
}
|
||||
let token = seed_test_api_token(api.store.as_ref(), "human-not-tracking-agent");
|
||||
let token =
|
||||
seed_test_workspace_owner_token(api.store.as_ref(), "human-not-tracking-agent").await;
|
||||
tracking_response(
|
||||
app,
|
||||
Request::builder()
|
||||
@@ -1308,7 +1311,7 @@ async fn assignment_clear_receipt_binds_verified_actor_and_runtime_binding() {
|
||||
.await,
|
||||
result
|
||||
);
|
||||
let token = seed_test_api_token(api.store.as_ref(), "another-clear-actor");
|
||||
let token = seed_test_workspace_owner_token(api.store.as_ref(), "another-clear-actor").await;
|
||||
tracking_response(
|
||||
app.clone(),
|
||||
Request::builder()
|
||||
@@ -1517,7 +1520,7 @@ async fn public_assignment_answer_authenticates_source_replays_saved_result_and_
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let (api, _, identity, source, ticket) = tracking_fixture(directory.path()).await;
|
||||
let current = assign_tracking(&api, &identity, &source, &ticket).await;
|
||||
let token = seed_test_api_token(api.store.as_ref(), "answer-owner");
|
||||
let token = seed_test_workspace_owner_token(api.store.as_ref(), "answer-owner").await;
|
||||
let app = build_router(api.clone());
|
||||
let path = format!("/api/w/{TEST_WORKSPACE_ID}/tickets/{ticket}/assignment-answer");
|
||||
let body = serde_json::json!({"assignment_id":current.assignment_id,"expected_last_event_id":current.last_event_id,"operation_key":"answer-op","body":"Choose the bounded option"});
|
||||
@@ -1755,7 +1758,7 @@ async fn catalog_assignment_routes_apply_cookie_workspace_auth_and_origin_to_bro
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let (api, _, identity, source, ticket) = tracking_fixture(directory.path()).await;
|
||||
let current = assign_tracking(&api, &identity, &source, &ticket).await;
|
||||
seed_test_api_token(api.store.as_ref(), "assignment-cookie");
|
||||
seed_test_workspace_owner_token(api.store.as_ref(), "assignment-cookie").await;
|
||||
api.store
|
||||
.create_browser_session(&BrowserSessionRecord {
|
||||
token_hash: crate::auth::token_hash("assignment-browser-session"),
|
||||
@@ -1846,7 +1849,7 @@ async fn catalog_assignment_routes_apply_cookie_workspace_auth_and_origin_to_bro
|
||||
.header(ORIGIN, origin)
|
||||
.body(Body::from(serde_json::to_vec(&body).unwrap()))
|
||||
.unwrap(),
|
||||
StatusCode::NOT_FOUND,
|
||||
StatusCode::FORBIDDEN,
|
||||
)
|
||||
.await;
|
||||
}
|
||||
@@ -1865,7 +1868,7 @@ async fn catalog_assignment_routes_apply_cookie_workspace_auth_and_origin_to_bro
|
||||
.author
|
||||
.as_deref()
|
||||
.unwrap()
|
||||
.contains("account-assignment-cookie")
|
||||
.contains(&format!("account-{TEST_WORKSPACE_ID}"))
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -577,7 +577,7 @@ async fn typed_and_generic_case_dispatch_keep_the_same_verified_worker_and_recei
|
||||
async fn authenticated_human_case_actor_and_replay_are_bound_to_verified_account_and_user() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let api = test_api(dir.path()).await;
|
||||
let token = seed_test_api_token(api.store.as_ref(), "case-human");
|
||||
let token = seed_test_workspace_owner_token(api.store.as_ref(), "case-human").await;
|
||||
let other_token = seed_test_api_token(api.store.as_ref(), "other-human");
|
||||
let backend = browser_ticket_backend(&api).unwrap();
|
||||
let reference = backend
|
||||
@@ -613,11 +613,17 @@ async fn authenticated_human_case_actor_and_replay_are_bound_to_verified_account
|
||||
let response = case_response(typed.clone(), human_request(&token, true), StatusCode::OK).await;
|
||||
let closed = backend.show(reference.id.clone().into()).unwrap();
|
||||
let event = closed.events.last().unwrap();
|
||||
assert_eq!(event.attributes["source_account_id"], "account-case-human");
|
||||
assert_eq!(
|
||||
event.attributes["source_account_id"],
|
||||
format!("account-{TEST_WORKSPACE_ID}")
|
||||
);
|
||||
assert_eq!(event.attributes["source_user_id"], "user-case-human");
|
||||
assert_eq!(
|
||||
event.author.as_deref(),
|
||||
Some("workspace-user:[\"account-case-human\",\"user-case-human\"]")
|
||||
Some(
|
||||
format!("workspace-user:[\"account-{TEST_WORKSPACE_ID}\",\"user-case-human\"]")
|
||||
.as_str()
|
||||
)
|
||||
);
|
||||
assert_eq!(
|
||||
closed.case_history.last().unwrap().author,
|
||||
@@ -630,7 +636,7 @@ async fn authenticated_human_case_actor_and_replay_are_bound_to_verified_account
|
||||
case_response(
|
||||
typed,
|
||||
human_request(&other_token, true),
|
||||
StatusCode::CONFLICT,
|
||||
StatusCode::FORBIDDEN,
|
||||
)
|
||||
.await;
|
||||
// Generic dispatch receives an explicit untrusted author but must override it.
|
||||
|
||||
@@ -858,16 +858,15 @@ async fn ticket_tag_routes_require_workspace_authority_and_reject_forged_worker_
|
||||
)
|
||||
.await;
|
||||
assert_eq!(tag["workspace_id"], TEST_WORKSPACE_ID);
|
||||
// Classification follows shared Ticket Workspace write authority, not the
|
||||
// owner-only repository/secrets-management policy. A distinct authenticated
|
||||
// actor may manage tags but cannot replay another actor's receipt.
|
||||
// An authenticated foreign account cannot mutate classification or replay
|
||||
// the owner's receipt; classification does not grant Workspace authority.
|
||||
request_json_authenticated(
|
||||
app.clone(),
|
||||
"POST",
|
||||
&path,
|
||||
Some(json!({"operation_key":"authorized", "action":{"kind":"create", "name":"ready"}})),
|
||||
&collaborator_token,
|
||||
StatusCode::CONFLICT,
|
||||
StatusCode::FORBIDDEN,
|
||||
)
|
||||
.await;
|
||||
request_json_authenticated(
|
||||
@@ -876,7 +875,7 @@ async fn ticket_tag_routes_require_workspace_authority_and_reject_forged_worker_
|
||||
"/api/w/other-workspace/ticket-tags",
|
||||
Some(json!({"operation_key":"foreign", "action":{"kind":"create", "name":"foreign"}})),
|
||||
&collaborator_token,
|
||||
StatusCode::NOT_FOUND,
|
||||
StatusCode::FORBIDDEN,
|
||||
)
|
||||
.await;
|
||||
let response = app
|
||||
|
||||
@@ -15,6 +15,38 @@
|
||||
//! migration before it starts. Updating only the new schema would diverge from existing DBs;
|
||||
//! both creation paths must maintain the same contract.
|
||||
//!
|
||||
//! # Hosting administrator setup
|
||||
//!
|
||||
//! Fresh and migrated databases have no hosting administrators and disable public
|
||||
//! self-registration. Users, Workspace owners and holders of the legacy bootstrap token
|
||||
//! are not promoted automatically. Hosting grants are separate from Workspace permissions.
|
||||
//!
|
||||
//! On the trusted Server host, run `yoi-server hosting-admin --database <PATH> --handle
|
||||
//! <NEW_HANDLE> --create-user` for a fresh installation. This atomically creates a user
|
||||
//! and explicit hosting grant, and intentionally prints one bearer token, valid for
|
||||
//! fifteen minutes. Keep stdout private: the token is a full user bearer, not a scoped
|
||||
//! activation code. Start the Server with its normal Web static assets and configured
|
||||
//! WebAuthn origin (HTTPS, or localhost). Open `/hosting/setup` on that trusted origin,
|
||||
//! enter the host-issued token in its password field, and choose Add administrator
|
||||
//! passkey. The page uses actor-derived addition, not public registration; it never
|
||||
//! stores the token or puts it in URLs. After enrollment choose Sign in with new
|
||||
//! passkey, then Open hosting settings. No Workspace or public registration is needed.
|
||||
//! The initial bearer expires automatically; never put it in URLs, docs or shared logs.
|
||||
//!
|
||||
//! For an existing installation, back up the DB and run migrations, then explicitly
|
||||
//! select a known user with `yoi-server hosting-admin --database <PATH> --handle
|
||||
//! <EXISTING_HANDLE>` without `--create-user`. Verify the handle belongs to the intended
|
||||
//! person first. This grants authority without minting or replacing login credentials;
|
||||
//! that person uses their existing passkey. `bootstrap-auth` retains its old host-user
|
||||
//! setup behavior but grants no hosting authority.
|
||||
//!
|
||||
//! Repeated creation refuses an existing handle and never refreshes a token. If the
|
||||
//! initial token is lost or expires before enrollment, a trusted host operator can
|
||||
//! explicitly create a different administrator handle and enroll it, then remove the
|
||||
//! unused user's grant and tokens through trusted DB maintenance after a backup.
|
||||
//! Selecting an existing user is not a credential recovery mechanism; no public
|
||||
//! bootstrap or unauthenticated administrator recovery endpoint exists.
|
||||
//!
|
||||
//! # Resource state and operation records
|
||||
//!
|
||||
//! Workspace bootstrap commits resources and creation receipts in one transaction. Jobs and
|
||||
@@ -60,6 +92,9 @@ use crate::backend_job::{
|
||||
use crate::workspace_deletion::WorkspaceDeletionStore;
|
||||
use crate::{Error, Result};
|
||||
|
||||
#[path = "activation.rs"]
|
||||
mod activation;
|
||||
pub use activation::ActivationCodeIssue;
|
||||
#[path = "assignment_delivery.rs"]
|
||||
mod assignment_delivery;
|
||||
#[path = "notification_diagnostics.rs"]
|
||||
@@ -84,7 +119,7 @@ pub use ticket::{
|
||||
};
|
||||
|
||||
const OLDEST_SCHEMA_VERSION: i64 = 50;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 98;
|
||||
const LATEST_SCHEMA_VERSION: i64 = 100;
|
||||
const WORKSPACE_DRIVE_GRANTS_MIGRATION_NAME: &str = "durable Workspace Drive grants";
|
||||
const WORKSPACE_CONFIG_GRANTS_MIGRATION_NAME: &str = "Workspace config grants and logical Workdirs";
|
||||
const SCHEMA_BASELINE_NAME: &str = "workspace schema baseline";
|
||||
@@ -382,6 +417,16 @@ const MIGRATIONS: &[Migration] = &[
|
||||
name: "Assignment-independent implementation reports",
|
||||
apply: migrate_result_reports_v97_to_v98,
|
||||
},
|
||||
Migration {
|
||||
version: 99,
|
||||
name: "Explicit hosting administrators and self-registration policy",
|
||||
apply: migrate_hosting_policy_v98_to_v99,
|
||||
},
|
||||
Migration {
|
||||
version: 100,
|
||||
name: "Provisional users and scoped passkey activation",
|
||||
apply: activation::migrate,
|
||||
},
|
||||
];
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
@@ -674,6 +719,10 @@ pub enum PasskeyRegistrationTarget {
|
||||
NewAccount {
|
||||
user: UserRecord,
|
||||
},
|
||||
Activation {
|
||||
user: UserRecord,
|
||||
issuance_id: String,
|
||||
},
|
||||
Addition {
|
||||
user: UserRecord,
|
||||
authentication: PasskeyAuthenticationBinding,
|
||||
@@ -1222,6 +1271,17 @@ impl WorkspaceResourceKind {
|
||||
}
|
||||
}
|
||||
|
||||
/// Result of an explicitly trusted host setup. Secrets deliberately have no Debug output.
|
||||
pub struct HostingAdminSetup {
|
||||
pub user: UserRecord,
|
||||
pub initial_bearer: Option<HostingAdminInitialBearer>,
|
||||
}
|
||||
|
||||
pub struct HostingAdminInitialBearer {
|
||||
pub access_token: String,
|
||||
pub expires_at: String,
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
|
||||
/// Bounded owner for whole synchronous operations from async callers. Move owned inputs
|
||||
@@ -1615,6 +1675,28 @@ pub trait ControlPlaneStore: Send + Sync + WorkspaceDeletionStore {
|
||||
fn get_user(&self, user_id: &str) -> Result<Option<UserRecord>>;
|
||||
fn get_user_by_handle(&self, handle: &str) -> Result<Option<UserRecord>>;
|
||||
fn any_user(&self) -> Result<Option<UserRecord>>;
|
||||
fn create_provisional_user(
|
||||
&self,
|
||||
actor: &server_api::RequestActor,
|
||||
handle: &str,
|
||||
) -> Result<ActivationCodeIssue>;
|
||||
fn reissue_activation_code(
|
||||
&self,
|
||||
actor: &server_api::RequestActor,
|
||||
handle: &str,
|
||||
) -> Result<ActivationCodeIssue>;
|
||||
fn verify_activation_code(&self, handle: &str, code: &str)
|
||||
-> Result<PasskeyRegistrationTarget>;
|
||||
fn is_user_pending_activation(&self, user_id: &str) -> Result<bool>;
|
||||
/// Checks an explicit grant for a currently existing user, not Workspace ownership.
|
||||
fn is_hosting_admin(&self, user_id: &str) -> Result<bool>;
|
||||
fn hosting_self_registration_enabled(&self) -> Result<bool>;
|
||||
/// Rechecks the actor's live user/account and hosting grant in the write transaction.
|
||||
fn set_hosting_self_registration_enabled(
|
||||
&self,
|
||||
actor: &server_api::RequestActor,
|
||||
enabled: bool,
|
||||
) -> Result<()>;
|
||||
/// Atomically creates a new account/user/credential, or inserts a credential
|
||||
/// for a still-authenticated existing target. Never overwrites a credential.
|
||||
fn register_verified_passkey(
|
||||
@@ -2608,6 +2690,64 @@ impl SqliteWorkspaceStore {
|
||||
})
|
||||
}
|
||||
|
||||
/// Host-only setup; never expose this operation through a public bootstrap route.
|
||||
/// Creating a user, granting hosting authority and issuing its short-lived initial
|
||||
/// bearer are atomic. Selecting an existing user never issues credentials. A repeat
|
||||
/// create is refused, including after output loss; it cannot mint another bearer.
|
||||
pub fn setup_hosting_admin(
|
||||
&self,
|
||||
handle: &str,
|
||||
create_user: bool,
|
||||
) -> Result<HostingAdminSetup> {
|
||||
let handle = crate::auth::normalize_handle(handle)?;
|
||||
self.with_immediate_transaction(|tx| {
|
||||
let existing = tx.query_row(
|
||||
user_select_sql("WHERE handle = ?1").as_str(),
|
||||
[&handle], read_user_record,
|
||||
).optional()?;
|
||||
let now = chrono::Utc::now();
|
||||
let created_at = now.to_rfc3339();
|
||||
let (user, initial_bearer) = match (existing, create_user) {
|
||||
(Some(_), true) => return Err(Error::Store("hosting-admin --create-user requires a new handle; no credentials issued".into())),
|
||||
(None, false) => return Err(Error::Store("hosting-admin requires an existing user; use --create-user explicitly for a new user".into())),
|
||||
(Some(user), false) => {
|
||||
activation::require_active(tx, &user.user_id)?;
|
||||
(user, None)
|
||||
},
|
||||
(None, true) => {
|
||||
let user = UserRecord {
|
||||
user_id: crate::auth::new_id("user"),
|
||||
account_id: crate::auth::new_id("acct-user"),
|
||||
handle: handle.clone(),
|
||||
display_name: handle.clone(),
|
||||
created_at: created_at.clone(),
|
||||
updated_at: created_at.clone(),
|
||||
};
|
||||
tx.execute(
|
||||
"INSERT INTO accounts(account_id,kind,handle,display_name,created_at,updated_at) VALUES(?1,'user',?2,?3,?4,?4)",
|
||||
params![user.account_id, user.handle, user.display_name, created_at],
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO users(user_id,account_id,handle,display_name,created_at,updated_at) VALUES(?1,?2,?3,?4,?5,?5)",
|
||||
params![user.user_id, user.account_id, user.handle, user.display_name, created_at],
|
||||
)?;
|
||||
let access_token = crate::auth::mint_secret("yoi_api");
|
||||
let expires_at = (now + chrono::Duration::minutes(15)).to_rfc3339();
|
||||
tx.execute(
|
||||
"INSERT INTO api_tokens(token_id,token_hash,user_id,label,created_at,expires_at) VALUES(?1,?2,?3,'initial hosting administrator passkey setup',?4,?5)",
|
||||
params![crate::auth::new_id("api-token"), crate::auth::token_hash(&access_token), user.user_id, created_at, expires_at],
|
||||
)?;
|
||||
(user, Some(HostingAdminInitialBearer { access_token, expires_at }))
|
||||
}
|
||||
};
|
||||
tx.execute(
|
||||
"INSERT INTO hosting_administrators(user_id,created_at) VALUES(?1,?2) ON CONFLICT(user_id) DO NOTHING",
|
||||
params![user.user_id, created_at],
|
||||
)?;
|
||||
Ok(HostingAdminSetup { user, initial_bearer })
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn get_internal_worker_restore_intent(
|
||||
&self,
|
||||
workspace_id: &str,
|
||||
@@ -4314,6 +4454,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()?;
|
||||
activation::require_active_account(&tx, &record.owner_account_id)?;
|
||||
if owner_kind.as_deref() != Some("user") {
|
||||
return Err(Error::Store(format!(
|
||||
"Workspace owner `{}` must reference an existing User Account",
|
||||
@@ -4495,6 +4636,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
|row| row.get::<_, String>(0),
|
||||
)
|
||||
.optional()?;
|
||||
activation::require_active_account(&tx, &record.workspace.owner_account_id)?;
|
||||
if owner_kind.as_deref() != Some("user") {
|
||||
return Err(Error::Store(format!(
|
||||
"Workspace owner `{}` must reference an existing User Account",
|
||||
@@ -6447,6 +6589,83 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
})
|
||||
}
|
||||
|
||||
fn create_provisional_user(
|
||||
&self,
|
||||
actor: &server_api::RequestActor,
|
||||
handle: &str,
|
||||
) -> Result<ActivationCodeIssue> {
|
||||
activation::create(self, actor, handle)
|
||||
}
|
||||
|
||||
fn reissue_activation_code(
|
||||
&self,
|
||||
actor: &server_api::RequestActor,
|
||||
handle: &str,
|
||||
) -> Result<ActivationCodeIssue> {
|
||||
activation::reissue(self, actor, handle)
|
||||
}
|
||||
|
||||
fn verify_activation_code(
|
||||
&self,
|
||||
handle: &str,
|
||||
code: &str,
|
||||
) -> Result<PasskeyRegistrationTarget> {
|
||||
activation::verify(self, handle, code)
|
||||
}
|
||||
|
||||
fn is_user_pending_activation(&self, user_id: &str) -> Result<bool> {
|
||||
self.with_conn(|conn| activation::pending(conn, user_id))
|
||||
}
|
||||
|
||||
fn is_hosting_admin(&self, user_id: &str) -> Result<bool> {
|
||||
self.with_conn(|conn| {
|
||||
if activation::pending(conn, user_id)? {
|
||||
return Ok(false);
|
||||
}
|
||||
conn.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM hosting_administrators h JOIN users u ON u.user_id = h.user_id WHERE u.user_id = ?1)",
|
||||
[user_id], |row| row.get(0),
|
||||
).map_err(Error::from)
|
||||
})
|
||||
}
|
||||
|
||||
fn hosting_self_registration_enabled(&self) -> Result<bool> {
|
||||
self.with_conn(|conn| {
|
||||
conn.query_row(
|
||||
"SELECT self_registration_enabled FROM hosting_settings WHERE singleton = 1",
|
||||
[],
|
||||
|row| row.get(0),
|
||||
)
|
||||
.map_err(Error::from)
|
||||
})
|
||||
}
|
||||
|
||||
fn set_hosting_self_registration_enabled(
|
||||
&self,
|
||||
actor: &server_api::RequestActor,
|
||||
enabled: bool,
|
||||
) -> Result<()> {
|
||||
self.with_immediate_transaction(|tx| {
|
||||
activation::require_active(tx, &actor.user_id)?;
|
||||
let allowed: bool = tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM hosting_administrators h JOIN users u ON u.user_id = h.user_id WHERE u.user_id = ?1 AND u.account_id = ?2)",
|
||||
params![actor.user_id, actor.account_id], |row| row.get(0),
|
||||
)?;
|
||||
if !allowed {
|
||||
return Err(Error::WorkspacePermissionDenied(
|
||||
"hosting_admin_required: a live hosting administrator is required".into(),
|
||||
));
|
||||
}
|
||||
if tx.execute(
|
||||
"UPDATE hosting_settings SET self_registration_enabled = ?1 WHERE singleton = 1",
|
||||
[enabled],
|
||||
)? != 1 {
|
||||
return Err(Error::Store("hosting settings singleton is missing".into()));
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn register_verified_passkey(
|
||||
&self,
|
||||
target: &PasskeyRegistrationTarget,
|
||||
@@ -6455,6 +6674,15 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
self.with_immediate_transaction(|tx| {
|
||||
let user = match target {
|
||||
PasskeyRegistrationTarget::NewAccount { user } => {
|
||||
let enabled: bool = tx.query_row(
|
||||
"SELECT self_registration_enabled FROM hosting_settings WHERE singleton = 1",
|
||||
[], |row| row.get(0),
|
||||
)?;
|
||||
if !enabled {
|
||||
return Err(Error::WorkspacePermissionDenied(
|
||||
"self_registration_disabled: hosting self-registration is disabled".into(),
|
||||
));
|
||||
}
|
||||
let occupied: bool = tx.query_row(
|
||||
"SELECT EXISTS(SELECT 1 FROM accounts WHERE kind = 'user' AND handle = ?1) OR EXISTS(SELECT 1 FROM users WHERE handle = ?1)",
|
||||
params![user.handle], |row| row.get(0),
|
||||
@@ -6472,7 +6700,12 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
)?;
|
||||
user
|
||||
}
|
||||
PasskeyRegistrationTarget::Activation { user, issuance_id } => {
|
||||
activation::require_pending_target(tx, user, Some(issuance_id))?;
|
||||
user
|
||||
}
|
||||
PasskeyRegistrationTarget::Addition { user, authentication } => {
|
||||
activation::require_active(tx, &user.user_id)?;
|
||||
let current = tx.query_row(
|
||||
user_select_sql("WHERE user_id = ?1 AND account_id = ?2").as_str(),
|
||||
params![user.user_id, user.account_id], read_user_record,
|
||||
@@ -6503,12 +6736,18 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
"INSERT INTO passkey_credentials (credential_id, user_id, public_key_cose, transports_json, sign_count, created_at, last_used_at) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
|
||||
params![credential.credential_id, credential.user_id, credential.public_key_cose, credential.transports_json, credential.sign_count, credential.created_at, credential.last_used_at],
|
||||
)?;
|
||||
if let PasskeyRegistrationTarget::Activation { user, issuance_id } = target {
|
||||
if tx.execute("DELETE FROM user_activations WHERE user_id=?1 AND issuance_id=?2", params![user.user_id, issuance_id])? != 1 {
|
||||
return Err(crate::auth::auth_error("activation_code_invalid", "activation issuance no longer exists"));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
fn upsert_passkey_credential(&self, record: &PasskeyCredentialRecord) -> Result<()> {
|
||||
self.with_conn(|conn| {
|
||||
self.with_immediate_transaction(|conn| {
|
||||
activation::require_active(conn, &record.user_id)?;
|
||||
conn.execute(
|
||||
r#"INSERT INTO passkey_credentials (credential_id, user_id, public_key_cose, transports_json, sign_count, created_at, last_used_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)
|
||||
@@ -6618,7 +6857,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
}
|
||||
|
||||
fn create_browser_session(&self, record: &BrowserSessionRecord) -> Result<()> {
|
||||
self.with_conn(|conn| {
|
||||
self.with_immediate_transaction(|conn| {
|
||||
activation::require_active(conn, &record.user_id)?;
|
||||
conn.execute(
|
||||
r#"INSERT INTO browser_sessions (session_id, token_hash, user_id, created_at, expires_at, revoked_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6)"#,
|
||||
@@ -6631,7 +6871,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
fn resolve_browser_session(&self, token_hash: &str) -> Result<Option<BrowserSessionRecord>> {
|
||||
self.with_conn(|conn| {
|
||||
conn.query_row(
|
||||
browser_session_select_sql("WHERE token_hash = ?1 AND revoked_at IS NULL").as_str(),
|
||||
browser_session_select_sql("WHERE token_hash = ?1 AND revoked_at IS NULL AND NOT EXISTS (SELECT 1 FROM user_activations p WHERE p.user_id=browser_sessions.user_id)").as_str(),
|
||||
params![token_hash],
|
||||
read_browser_session_record,
|
||||
)
|
||||
@@ -6651,7 +6891,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
}
|
||||
|
||||
fn create_api_token(&self, record: &ApiTokenRecord) -> Result<()> {
|
||||
self.with_conn(|conn| {
|
||||
self.with_immediate_transaction(|conn| {
|
||||
activation::require_active(conn, &record.user_id)?;
|
||||
conn.execute(
|
||||
r#"INSERT INTO api_tokens (token_id, token_hash, user_id, label, created_at, expires_at, revoked_at, last_used_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)"#,
|
||||
@@ -6664,7 +6905,7 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
fn resolve_api_token(&self, token_hash: &str) -> Result<Option<ApiTokenRecord>> {
|
||||
self.with_conn(|conn| {
|
||||
conn.query_row(
|
||||
api_token_select_sql("WHERE token_hash = ?1 AND revoked_at IS NULL").as_str(),
|
||||
api_token_select_sql("WHERE token_hash = ?1 AND revoked_at IS NULL AND NOT EXISTS (SELECT 1 FROM user_activations p WHERE p.user_id=api_tokens.user_id)").as_str(),
|
||||
params![token_hash],
|
||||
read_api_token_record,
|
||||
)
|
||||
@@ -6684,7 +6925,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
}
|
||||
|
||||
fn try_create_device_login_flow(&self, record: &DeviceLoginFlowRecord) -> Result<bool> {
|
||||
self.with_conn(|conn| {
|
||||
self.with_immediate_transaction(|conn| {
|
||||
if let Some(user_id) = &record.user_id { activation::require_active(conn, user_id)?; }
|
||||
let changed = conn.execute(
|
||||
r#"INSERT INTO device_login_flows (device_code, user_code, verification_uri, client_name, user_id, api_token_id, issued_access_token, created_at, expires_at, approved_at, consumed_at)
|
||||
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11)
|
||||
@@ -6733,7 +6975,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
issued_access_token: &str,
|
||||
approved_at: &str,
|
||||
) -> Result<bool> {
|
||||
self.with_conn(|conn| {
|
||||
self.with_immediate_transaction(|conn| {
|
||||
activation::require_active(conn, user_id)?;
|
||||
let changed = conn.execute(
|
||||
r#"UPDATE device_login_flows
|
||||
SET user_id = ?2, api_token_id = ?3, issued_access_token = ?4, approved_at = ?5
|
||||
@@ -6755,8 +6998,8 @@ impl ControlPlaneStore for SqliteWorkspaceStore {
|
||||
device_code: &str,
|
||||
consumed_at: &str,
|
||||
) -> Result<Option<DeviceLoginFlowRecord>> {
|
||||
self.with_conn(|conn| {
|
||||
let record = conn.query_row(device_login_select_sql("WHERE device_code = ?1 AND approved_at IS NOT NULL AND consumed_at IS NULL").as_str(), params![device_code], read_device_login_flow_record).optional()?;
|
||||
self.with_immediate_transaction(|conn| {
|
||||
let record = conn.query_row(device_login_select_sql("WHERE device_code = ?1 AND approved_at IS NOT NULL AND consumed_at IS NULL AND NOT EXISTS(SELECT 1 FROM user_activations p WHERE p.user_id=device_login_flows.user_id)").as_str(), params![device_code], read_device_login_flow_record).optional()?;
|
||||
if record.is_some() {
|
||||
conn.execute("UPDATE device_login_flows SET consumed_at = ?2, issued_access_token = NULL WHERE device_code = ?1", params![device_code, consumed_at])?;
|
||||
}
|
||||
@@ -14597,6 +14840,33 @@ fn migrate_result_reports_v97_to_v98(conn: &Connection) -> Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn migrate_hosting_policy_v98_to_v99(conn: &Connection) -> Result<()> {
|
||||
if current_schema_version(conn)? != 98 {
|
||||
return Err(Error::Store(
|
||||
"Hosting policy migration requires schema 98".into(),
|
||||
));
|
||||
}
|
||||
let tx = conn.unchecked_transaction()?;
|
||||
tx.execute_batch(
|
||||
"CREATE TABLE hosting_administrators (
|
||||
user_id TEXT PRIMARY KEY REFERENCES users(user_id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL
|
||||
);
|
||||
CREATE TABLE hosting_settings (
|
||||
singleton INTEGER PRIMARY KEY CHECK (singleton = 1),
|
||||
self_registration_enabled INTEGER NOT NULL DEFAULT 0 CHECK (self_registration_enabled IN (0, 1))
|
||||
);
|
||||
INSERT INTO hosting_settings(singleton, self_registration_enabled) VALUES(1, 0);",
|
||||
)?;
|
||||
tx.execute(
|
||||
"INSERT INTO __yoi_schema_migrations(version,name) VALUES(99,?1)",
|
||||
["Explicit hosting administrators and self-registration policy"],
|
||||
)?;
|
||||
verify_workspace_resource_constraints(&tx)?;
|
||||
tx.commit()?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
|
||||
create_workspace_schema_structure(conn)?;
|
||||
assignment_delivery::install(conn)?;
|
||||
@@ -14615,7 +14885,9 @@ fn create_latest_workspace_schema(conn: &Connection) -> Result<()> {
|
||||
// Fresh composition uses the posts contract. Retained fixtures pass frozen host
|
||||
// and component DDL to the shared cross-domain constraint composition below.
|
||||
fn create_workspace_schema_structure(conn: &Connection) -> Result<()> {
|
||||
create_workspace_schema_structure_from_sql(conn, include_str!("latest_schema.sql"), true)
|
||||
create_workspace_schema_structure_from_sql(conn, include_str!("latest_schema.sql"), true)?;
|
||||
conn.execute_batch(include_str!("activation_schema.sql"))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn create_workspace_schema_structure_from_sql(
|
||||
@@ -17501,6 +17773,369 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn hosting_user(handle: &str) -> UserRecord {
|
||||
UserRecord {
|
||||
user_id: format!("user-{handle}"),
|
||||
account_id: format!("account-{handle}"),
|
||||
handle: handle.into(),
|
||||
display_name: handle.into(),
|
||||
created_at: "1".into(),
|
||||
updated_at: "1".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn hosting_seed_user(store: &SqliteWorkspaceStore, handle: &str) -> UserRecord {
|
||||
let user = hosting_user(handle);
|
||||
store
|
||||
.upsert_account(&AccountRecord {
|
||||
account_id: user.account_id.clone(),
|
||||
kind: "user".into(),
|
||||
handle: user.handle.clone(),
|
||||
display_name: user.display_name.clone(),
|
||||
created_at: "1".into(),
|
||||
updated_at: "1".into(),
|
||||
})
|
||||
.unwrap();
|
||||
store.upsert_user(&user).unwrap();
|
||||
user
|
||||
}
|
||||
|
||||
fn hosting_actor(user: &UserRecord) -> server_api::RequestActor {
|
||||
server_api::RequestActor {
|
||||
user_id: user.user_id.clone(),
|
||||
account_id: user.account_id.clone(),
|
||||
handle: user.handle.clone(),
|
||||
display_name: user.display_name.clone(),
|
||||
auth_method: server_api::ActorAuthMethod::ApiToken,
|
||||
}
|
||||
}
|
||||
|
||||
fn hosting_credential(user: &UserRecord, id: &str) -> PasskeyCredentialRecord {
|
||||
PasskeyCredentialRecord {
|
||||
credential_id: id.into(),
|
||||
user_id: user.user_id.clone(),
|
||||
public_key_cose: "verified-key".into(),
|
||||
transports_json: None,
|
||||
sign_count: 0,
|
||||
created_at: "1".into(),
|
||||
last_used_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosting_policy_migrates_oldest_and_previous_databases_without_promoting_users() {
|
||||
for baseline in [50, 98] {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("server.db");
|
||||
if baseline == 50 {
|
||||
prepare_schema_v50(&path, None);
|
||||
} else {
|
||||
prepare_retained_schema(&path, baseline);
|
||||
}
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
conn.execute_batch("INSERT OR IGNORE INTO accounts VALUES('old-account','user','old-user','Old','1','1');
|
||||
INSERT INTO users VALUES('old-user','old-account','old-user','Old','1','1');").unwrap();
|
||||
drop(conn);
|
||||
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
assert!(!store.hosting_self_registration_enabled().unwrap());
|
||||
assert!(!store.is_hosting_admin("old-user").unwrap());
|
||||
assert_eq!(
|
||||
store.get_user("old-user").unwrap().unwrap().display_name,
|
||||
"Old"
|
||||
);
|
||||
let fresh = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
||||
for table in ["hosting_settings", "hosting_administrators"] {
|
||||
let schema = |store: &SqliteWorkspaceStore| {
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
let ddl: String = conn.query_row(
|
||||
"SELECT sql FROM sqlite_schema WHERE name=?1",
|
||||
[table],
|
||||
|r| r.get(0),
|
||||
)?;
|
||||
Ok(ddl
|
||||
.chars()
|
||||
.filter(|c| !c.is_whitespace())
|
||||
.collect::<String>())
|
||||
})
|
||||
.unwrap()
|
||||
};
|
||||
assert_eq!(schema(&store), schema(&fresh));
|
||||
}
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
assert!(
|
||||
conn.execute(
|
||||
"INSERT INTO hosting_administrators VALUES('missing','1')",
|
||||
[]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
conn.execute(
|
||||
"UPDATE hosting_settings SET self_registration_enabled=2",
|
||||
[]
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(
|
||||
conn.execute("INSERT INTO hosting_settings VALUES(2,0)", [])
|
||||
.is_err()
|
||||
);
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT count(*) FROM pragma_foreign_key_check", [], |r| r
|
||||
.get::<_, i64>(0))?,
|
||||
0
|
||||
);
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosting_policy_migration_failure_rolls_back_new_tables_and_history() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("server.db");
|
||||
prepare_retained_schema(&path, 98);
|
||||
let conn = Connection::open(&path).unwrap();
|
||||
configure_sqlite(&conn).unwrap();
|
||||
// A conflicting second DDL statement fails after the first table was created.
|
||||
conn.execute_batch("CREATE TABLE hosting_settings(original TEXT); INSERT INTO hosting_settings VALUES('retained');").unwrap();
|
||||
assert!(migrate_hosting_policy_v98_to_v99(&conn).is_err());
|
||||
assert_eq!(current_schema_version(&conn).unwrap(), 98);
|
||||
assert!(!table_exists(&conn, "hosting_administrators").unwrap());
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT original FROM hosting_settings", [], |r| r
|
||||
.get::<_, String>(0))
|
||||
.unwrap(),
|
||||
"retained"
|
||||
);
|
||||
conn.execute_batch("DROP TABLE hosting_settings").unwrap();
|
||||
drop(conn);
|
||||
let store = SqliteWorkspaceStore::open_for_test(path).unwrap();
|
||||
assert!(!store.hosting_self_registration_enabled().unwrap());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn hosting_policy_requires_explicit_live_admin_and_persists_across_reopen() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("server.db");
|
||||
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
let owner = hosting_seed_user(&store, "owner");
|
||||
store
|
||||
.upsert_workspace(&WorkspaceRecord {
|
||||
workspace_id: "workspace".into(),
|
||||
owner_account_id: owner.account_id.clone(),
|
||||
display_name: "Workspace".into(),
|
||||
state: "active".into(),
|
||||
created_at: "1".into(),
|
||||
updated_at: "1".into(),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
assert!(!store.is_hosting_admin(&owner.user_id).unwrap());
|
||||
assert!(matches!(
|
||||
store.set_hosting_self_registration_enabled(&hosting_actor(&owner), true),
|
||||
Err(Error::WorkspacePermissionDenied(_))
|
||||
));
|
||||
assert!(!store.hosting_self_registration_enabled().unwrap());
|
||||
let setup = store.setup_hosting_admin("owner", false).unwrap();
|
||||
assert!(setup.initial_bearer.is_none());
|
||||
let mut forged = hosting_actor(&owner);
|
||||
forged.account_id = "another-account".into();
|
||||
assert!(matches!(
|
||||
store.set_hosting_self_registration_enabled(&forged, true),
|
||||
Err(Error::WorkspacePermissionDenied(_))
|
||||
));
|
||||
store
|
||||
.set_hosting_self_registration_enabled(&hosting_actor(&owner), true)
|
||||
.unwrap();
|
||||
drop(store);
|
||||
let store = SqliteWorkspaceStore::open_for_test(&path).unwrap();
|
||||
assert!(store.hosting_self_registration_enabled().unwrap());
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
conn.execute("DELETE FROM hosting_administrators", [])?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(matches!(
|
||||
store.set_hosting_self_registration_enabled(&hosting_actor(&owner), false),
|
||||
Err(Error::WorkspacePermissionDenied(_))
|
||||
));
|
||||
assert!(store.hosting_self_registration_enabled().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosting_setup_creates_finite_bearer_once_and_existing_selection_mints_nothing() {
|
||||
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
||||
assert!(!store.hosting_self_registration_enabled().unwrap());
|
||||
assert!(store.setup_hosting_admin("unknown", false).is_err());
|
||||
let setup = store.setup_hosting_admin("new-admin", true).unwrap();
|
||||
assert!(store.is_hosting_admin(&setup.user.user_id).unwrap());
|
||||
let bearer = setup.initial_bearer.unwrap();
|
||||
let token = store
|
||||
.resolve_api_token(&crate::auth::token_hash(&bearer.access_token))
|
||||
.unwrap()
|
||||
.unwrap();
|
||||
assert_eq!(token.user_id, setup.user.user_id);
|
||||
assert_eq!(
|
||||
token.expires_at.as_deref(),
|
||||
Some(bearer.expires_at.as_str())
|
||||
);
|
||||
let lifetime = chrono::DateTime::parse_from_rfc3339(&bearer.expires_at).unwrap()
|
||||
- chrono::DateTime::parse_from_rfc3339(&token.created_at).unwrap();
|
||||
assert_eq!(lifetime.num_seconds(), 15 * 60);
|
||||
assert!(store.setup_hosting_admin("new-admin", true).is_err());
|
||||
assert!(
|
||||
store
|
||||
.setup_hosting_admin("new-admin", false)
|
||||
.unwrap()
|
||||
.initial_bearer
|
||||
.is_none()
|
||||
);
|
||||
let existing = hosting_seed_user(&store, "existing");
|
||||
assert!(!store.is_hosting_admin(&existing.user_id).unwrap());
|
||||
assert!(
|
||||
store
|
||||
.setup_hosting_admin("existing", false)
|
||||
.unwrap()
|
||||
.initial_bearer
|
||||
.is_none()
|
||||
);
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
assert_eq!(
|
||||
conn.query_row("SELECT count(*) FROM api_tokens", [], |r| r
|
||||
.get::<_, i64>(0))?,
|
||||
1
|
||||
);
|
||||
conn.execute("DELETE FROM users WHERE user_id=?1", [&setup.user.user_id])?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!store.is_hosting_admin(&setup.user.user_id).unwrap());
|
||||
assert!(matches!(
|
||||
store.set_hosting_self_registration_enabled(&hosting_actor(&setup.user), true),
|
||||
Err(Error::WorkspacePermissionDenied(_))
|
||||
));
|
||||
assert!(!store.hosting_self_registration_enabled().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosting_setup_failure_rolls_back_account_user_token_and_grant() {
|
||||
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
||||
store.with_conn(|conn| { conn.execute_batch("CREATE TRIGGER reject_admin BEFORE INSERT ON hosting_administrators BEGIN SELECT RAISE(ABORT,'injected grant failure'); END;")?; Ok(()) }).unwrap();
|
||||
assert!(store.setup_hosting_admin("admin", true).is_err());
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
for table in ["accounts", "users", "api_tokens", "hosting_administrators"] {
|
||||
assert_eq!(
|
||||
conn.query_row(&format!("SELECT count(*) FROM {table}"), [], |r| r
|
||||
.get::<_, i64>(0))?,
|
||||
0
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(!store.hosting_self_registration_enabled().unwrap());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn hosting_registration_rechecks_policy_but_allows_authenticated_addition() {
|
||||
let store = SqliteWorkspaceStore::in_memory_for_test().unwrap();
|
||||
let admin = store.setup_hosting_admin("admin", true).unwrap();
|
||||
let new_user = hosting_user("visitor");
|
||||
let target = PasskeyRegistrationTarget::NewAccount {
|
||||
user: new_user.clone(),
|
||||
};
|
||||
let credential = hosting_credential(&new_user, "visitor-key");
|
||||
let assert_denied = || {
|
||||
let error = store
|
||||
.register_verified_passkey(&target, &credential)
|
||||
.unwrap_err();
|
||||
assert!(
|
||||
matches!(error, Error::WorkspacePermissionDenied(ref message) if message.contains("self_registration_disabled"))
|
||||
);
|
||||
assert!(store.get_user(&new_user.user_id).unwrap().is_none());
|
||||
assert!(store.get_account(&new_user.account_id).unwrap().is_none());
|
||||
assert!(
|
||||
store
|
||||
.get_passkey_credential("visitor-key")
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
};
|
||||
assert_denied();
|
||||
store
|
||||
.set_hosting_self_registration_enabled(&hosting_actor(&admin.user), true)
|
||||
.unwrap();
|
||||
// Simulate an enrollment allocated while enabled, then stopped before completion.
|
||||
store
|
||||
.set_hosting_self_registration_enabled(&hosting_actor(&admin.user), false)
|
||||
.unwrap();
|
||||
assert_denied();
|
||||
let token_hash = crate::auth::token_hash(&admin.initial_bearer.unwrap().access_token);
|
||||
let addition = PasskeyRegistrationTarget::Addition {
|
||||
user: admin.user.clone(),
|
||||
authentication: PasskeyAuthenticationBinding::ApiToken(token_hash.clone()),
|
||||
};
|
||||
store
|
||||
.register_verified_passkey(&addition, &hosting_credential(&admin.user, "admin-key"))
|
||||
.unwrap();
|
||||
assert!(store.get_passkey_credential("admin-key").unwrap().is_some());
|
||||
store
|
||||
.with_conn(|conn| {
|
||||
conn.execute(
|
||||
"UPDATE api_tokens SET revoked_at=?1 WHERE token_hash=?2",
|
||||
params![crate::auth::now_rfc3339(), token_hash],
|
||||
)?;
|
||||
Ok(())
|
||||
})
|
||||
.unwrap();
|
||||
assert!(
|
||||
store
|
||||
.register_verified_passkey(
|
||||
&addition,
|
||||
&hosting_credential(&admin.user, "revoked-key")
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
store
|
||||
.set_hosting_self_registration_enabled(&hosting_actor(&admin.user), true)
|
||||
.unwrap();
|
||||
store
|
||||
.register_verified_passkey(&target, &credential)
|
||||
.unwrap();
|
||||
assert_eq!(
|
||||
store.get_user(&new_user.user_id).unwrap(),
|
||||
Some(new_user.clone())
|
||||
);
|
||||
assert!(!store.is_hosting_admin(&new_user.user_id).unwrap());
|
||||
// A duplicate credential fails after account/user insertion and must roll both back.
|
||||
let failed_user = hosting_user("failed");
|
||||
assert!(
|
||||
store
|
||||
.register_verified_passkey(
|
||||
&PasskeyRegistrationTarget::NewAccount {
|
||||
user: failed_user.clone()
|
||||
},
|
||||
&hosting_credential(&failed_user, "visitor-key")
|
||||
)
|
||||
.is_err()
|
||||
);
|
||||
assert!(store.get_user(&failed_user.user_id).unwrap().is_none());
|
||||
assert!(
|
||||
store
|
||||
.get_account(&failed_user.account_id)
|
||||
.unwrap()
|
||||
.is_none()
|
||||
);
|
||||
}
|
||||
|
||||
include!("activation_tests.rs");
|
||||
include!("schema_equivalence_tests.rs");
|
||||
include!("ticket_tag_upgrade_tests.rs");
|
||||
include!("ticket_post_upgrade_tests.rs");
|
||||
@@ -17910,6 +18545,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
WorkspaceSchemaMigrationStep {version:96, name:"Backend Job common notification routing".into()},
|
||||
WorkspaceSchemaMigrationStep {version:97, name:"MR notifications and authenticated implementation results".into()},
|
||||
WorkspaceSchemaMigrationStep {version:98, name:"Assignment-independent implementation reports".into()},
|
||||
WorkspaceSchemaMigrationStep {version:99, name:"Explicit hosting administrators and self-registration policy".into()},
|
||||
WorkspaceSchemaMigrationStep {version:100, name:"Provisional users and scoped passkey activation".into()},
|
||||
]
|
||||
);
|
||||
|
||||
@@ -18018,6 +18655,8 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
(96, "Backend Job common notification routing".to_string()),
|
||||
(97, "MR notifications and authenticated implementation results".to_string()),
|
||||
(98, "Assignment-independent implementation reports".to_string()),
|
||||
(99, "Explicit hosting administrators and self-registration policy".to_string()),
|
||||
(100, "Provisional users and scoped passkey activation".to_string()),
|
||||
]
|
||||
);
|
||||
assert!(!table_exists(conn, "trusted_runtime_records")?);
|
||||
@@ -20631,7 +21270,9 @@ CREATE UNIQUE INDEX ticket_current_worker_role_idx
|
||||
(95, "Common notification routing and durable Worker delivery".into()),
|
||||
(96, "Backend Job common notification routing".into()),
|
||||
(97, "MR notifications and authenticated implementation results".into()),
|
||||
(98, "Assignment-independent implementation reports".into())]);
|
||||
(98, "Assignment-independent implementation reports".into()),
|
||||
(99, "Explicit hosting administrators and self-registration policy".into()),
|
||||
(100, "Provisional users and scoped passkey activation".into())]);
|
||||
for (table, rows) in unchanged.iter().zip(before.iter()) {
|
||||
assert_eq!(&stored_fixture_rows(conn, table), rows, "preserve {table}");
|
||||
}
|
||||
|
||||
+515
-1
@@ -31,6 +31,50 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ActivationCodeResponse": {
|
||||
"additionalProperties": false,
|
||||
"description": "The only response allowed to disclose an activation code. Clients must keep it\nout of URLs, persistent browser storage and diagnostics. A lost response is\nrecovered by reissuing, which invalidates the previous code and its ceremonies.",
|
||||
"properties": {
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"expires_at": {
|
||||
"type": "string"
|
||||
},
|
||||
"user": {
|
||||
"$ref": "#/components/schemas/AuthenticatedUser"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"user",
|
||||
"code",
|
||||
"expires_at"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ActivationOptionsRequest": {
|
||||
"additionalProperties": false,
|
||||
"description": "Proof for a pending account's initial passkey ceremony, never a bearer token\nor browser session. No caller-selected account ID or enrollment purpose is accepted.",
|
||||
"properties": {
|
||||
"browser_origin": {
|
||||
"type": [
|
||||
"string",
|
||||
"null"
|
||||
]
|
||||
},
|
||||
"code": {
|
||||
"type": "string"
|
||||
},
|
||||
"handle": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"handle",
|
||||
"code"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ActorAuthMethod": {
|
||||
"description": "Authentication method that established the current request actor.",
|
||||
"enum": [
|
||||
@@ -3416,6 +3460,32 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"HostingAccess": {
|
||||
"additionalProperties": false,
|
||||
"description": "Hosting authority for the current authenticated human, not Workspace permissions.",
|
||||
"properties": {
|
||||
"is_admin": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"is_admin"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"HostingSettings": {
|
||||
"additionalProperties": false,
|
||||
"description": "The only public registration policy. No administrator identities or host secrets are exposed.",
|
||||
"properties": {
|
||||
"self_registration_enabled": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"self_registration_enabled"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"InitialRepositoryIntent": {
|
||||
"additionalProperties": false,
|
||||
"description": "Initial Repository registration intent for Workspace creation.",
|
||||
@@ -6799,6 +6869,19 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"ProvisionalAccountRequest": {
|
||||
"additionalProperties": false,
|
||||
"description": "A hosting administrator reserves the same normalized ID used by passkey login.",
|
||||
"properties": {
|
||||
"handle": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"handle"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"PublicMergeRequest": {
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
@@ -17748,10 +17831,111 @@
|
||||
"info": {
|
||||
"title": "Yoi Server API",
|
||||
"version": "0.1.0",
|
||||
"x-yoi-source-digest": "sha256:21dfcde7b03c0b08f9ede1012962ff69a05d4f8373d5a9c5abe6ab43ba5c3720"
|
||||
"x-yoi-source-digest": "sha256:d6241af8f70efb7be45195dc27c01e36517d13484cd38f71490ce5d253dd84e8"
|
||||
},
|
||||
"openapi": "3.1.0",
|
||||
"paths": {
|
||||
"/api/auth/activation/complete": {
|
||||
"post": {
|
||||
"operationId": "auth_activation_complete",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PasskeyRegistrationCompleteRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/AuthUserResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful response",
|
||||
"headers": {
|
||||
"set-cookie": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/SetCookieHeader"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"500": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/activation/options": {
|
||||
"post": {
|
||||
"operationId": "auth_activation_options",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ActivationOptionsRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PasskeyRegistrationOptionsResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful response"
|
||||
},
|
||||
"400": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"500": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/bootstrap-user": {
|
||||
"post": {
|
||||
"operationId": "auth_bootstrap_user",
|
||||
@@ -18450,6 +18634,33 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/registration-policy": {
|
||||
"get": {
|
||||
"operationId": "auth_registration_policy",
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HostingSettings"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful response"
|
||||
},
|
||||
"400": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/api/auth/whoami": {
|
||||
"get": {
|
||||
"operationId": "auth_whoami",
|
||||
@@ -18769,6 +18980,309 @@
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/hosting/access": {
|
||||
"get": {
|
||||
"operationId": "hosting_access",
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HostingAccess"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful response"
|
||||
},
|
||||
"400": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"500": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
},
|
||||
{
|
||||
"browserSession": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/hosting/accounts": {
|
||||
"post": {
|
||||
"operationId": "hosting_account_create",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProvisionalAccountRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ActivationCodeResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful response"
|
||||
},
|
||||
"400": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"500": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
},
|
||||
{
|
||||
"browserSession": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/hosting/accounts/activation-code": {
|
||||
"post": {
|
||||
"operationId": "hosting_activation_code_reissue",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProvisionalAccountRequest"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ActivationCodeResponse"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful response"
|
||||
},
|
||||
"400": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"500": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
},
|
||||
{
|
||||
"browserSession": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/hosting/settings": {
|
||||
"get": {
|
||||
"operationId": "hosting_settings",
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HostingSettings"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful response"
|
||||
},
|
||||
"400": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"500": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
},
|
||||
{
|
||||
"browserSession": []
|
||||
}
|
||||
]
|
||||
},
|
||||
"put": {
|
||||
"operationId": "hosting_settings_update",
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HostingSettings"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/HostingSettings"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Successful response"
|
||||
},
|
||||
"400": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"403": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
},
|
||||
"500": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/RepositoryApiError"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": "Error response"
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearerAuth": []
|
||||
},
|
||||
{
|
||||
"browserSession": []
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/api/hosts": {
|
||||
"get": {
|
||||
"operationId": "host_list_alias",
|
||||
|
||||
@@ -309,6 +309,47 @@ impl WorkerE2eEnvironment {
|
||||
pub fn start_empty_backend(&mut self) -> Result<()> {
|
||||
assert_absent_or_empty(&self.server_data_dir, "Server data directory")?;
|
||||
assert_absent_or_empty(&self.runtime_data_dir, "Runtime data directory")?;
|
||||
self.start_server_process()
|
||||
}
|
||||
|
||||
/// 同じ隔離DB・設定でBackendを再起動する。Runtime付きfixtureには使わない。
|
||||
pub fn restart_backend(&mut self) -> Result<()> {
|
||||
if self.runtime.is_some() || self.server.is_none() {
|
||||
return Err(HarnessError::Protocol(
|
||||
"Backend-only restart requires a running Server and no Runtime".to_string(),
|
||||
));
|
||||
}
|
||||
if let Some(mut server) = self.server.take() {
|
||||
server.shutdown(EXIT_WAIT)?;
|
||||
}
|
||||
self.server_url = None;
|
||||
self.start_server_process()
|
||||
}
|
||||
|
||||
/// host CLIだけで管理者を明示設定する。初回bearerはartifactへ保存しない。
|
||||
pub fn setup_hosting_admin(&self, handle: &str, create_user: bool) -> Result<Value> {
|
||||
let binary = self.binaries.binary("yoi-server")?;
|
||||
let mut args = vec!["hosting-admin", "--handle", handle];
|
||||
if create_user {
|
||||
args.push("--create-user");
|
||||
}
|
||||
let output = self.run_command(&binary, &args, true)?;
|
||||
Ok(serde_json::from_slice(&output.stdout)?)
|
||||
}
|
||||
|
||||
/// 旧host bootstrap CLIは一般userを作る。認証情報はartifactへ保存しない。
|
||||
pub fn bootstrap_auth_user(&self, handle: &str) -> Result<Value> {
|
||||
let binary = self.binaries.binary("yoi-server")?;
|
||||
let output = self.run_command(&binary, &["bootstrap-auth", "--handle", handle], true)?;
|
||||
Ok(serde_json::from_slice(&output.stdout)?)
|
||||
}
|
||||
|
||||
fn start_server_process(&mut self) -> Result<()> {
|
||||
if self.server.is_some() {
|
||||
return Err(HarnessError::Protocol(
|
||||
"Server is already running".to_string(),
|
||||
));
|
||||
}
|
||||
let server_binary = self.binaries.binary("yoi-server")?;
|
||||
let mut command = self.isolated_command(&server_binary);
|
||||
command.args([
|
||||
|
||||
+1689
-23
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,386 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "hosting-settings-dark",
|
||||
"title": "Hosting settings: synthetic UI conditions on production shell",
|
||||
"baseUrl": "http://127.0.0.1:15378",
|
||||
"colorScheme": "dark",
|
||||
"reducedMotion": "reduce",
|
||||
"personas": [
|
||||
{
|
||||
"id": "fixture",
|
||||
"label": "Synthetic API projection; no real authentication",
|
||||
"auth": {
|
||||
"kind": "anonymous"
|
||||
}
|
||||
}
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"label": "desktop",
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"label": "narrow",
|
||||
"width": 768,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"label": "mobile",
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"id": "admin-on",
|
||||
"label": "admin-on",
|
||||
"path": "/hosting/settings?fixture=admin-on",
|
||||
"goal": "Verify production shell UI projection, not server authorization.",
|
||||
"dataState": "Synthetic admin; policy on.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "button:has-text(\"Edit registration policy\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Loaded hosting-wide policy"
|
||||
},
|
||||
{
|
||||
"id": "edit",
|
||||
"label": "Keyboard edit focus",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "press",
|
||||
"selector": "button:has-text(\"Edit registration policy\")",
|
||||
"key": "Enter"
|
||||
}
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "input[type=\"checkbox\"]:focus"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "saved",
|
||||
"label": "Confirmed save",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "press",
|
||||
"selector": "input[type=\"checkbox\"]",
|
||||
"key": "Space"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": "button:has-text(\"Save settings\")"
|
||||
}
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[role=\"status\"]:has-text(\"Hosting settings saved.\")"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "admin-off",
|
||||
"label": "admin-off",
|
||||
"path": "/hosting/settings?fixture=admin-off",
|
||||
"goal": "Verify production shell UI projection, not server authorization.",
|
||||
"dataState": "Synthetic admin; policy off.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "button:has-text(\"Edit registration policy\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Loaded hosting-wide policy"
|
||||
},
|
||||
{
|
||||
"id": "edit",
|
||||
"label": "Keyboard edit focus",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "press",
|
||||
"selector": "button:has-text(\"Edit registration policy\")",
|
||||
"key": "Enter"
|
||||
}
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "input[type=\"checkbox\"]:focus"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "saved",
|
||||
"label": "Confirmed save",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "press",
|
||||
"selector": "input[type=\"checkbox\"]",
|
||||
"key": "Space"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": "button:has-text(\"Save settings\")"
|
||||
}
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[role=\"status\"]:has-text(\"Hosting settings saved.\")"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ordinary",
|
||||
"label": "ordinary",
|
||||
"path": "/hosting/settings?fixture=ordinary",
|
||||
"goal": "Verify production shell UI projection, not server authorization.",
|
||||
"dataState": "Synthetic authenticated ordinary user, no admin controls.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "p:has-text(\"Hosting administrator access is required.\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "anonymous",
|
||||
"label": "anonymous",
|
||||
"path": "/hosting/settings?fixture=anonymous",
|
||||
"goal": "Verify production shell UI projection, not server authorization.",
|
||||
"dataState": "Synthetic anonymous visitor; sign-in guidance, no controls.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "a:has-text(\"Open Account\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "admin-loading",
|
||||
"label": "admin-loading",
|
||||
"path": "/hosting/settings?fixture=admin-loading",
|
||||
"goal": "Verify production shell UI projection, not server authorization.",
|
||||
"dataState": "Settings GET intentionally held pending; shell remains usable.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[role=\"status\"]:has-text(\"Loading hosting settings\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "admin-load-error",
|
||||
"label": "admin-load-error",
|
||||
"path": "/hosting/settings?fixture=admin-load-error",
|
||||
"goal": "Verify production shell UI projection, not server authorization.",
|
||||
"dataState": "Intentional HTTP503; expected console resource error and visible alert.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "button:has-text(\"Retry loading settings\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "admin-saving",
|
||||
"label": "admin-saving",
|
||||
"path": "/hosting/settings?fixture=admin-saving",
|
||||
"goal": "Verify production shell UI projection, not server authorization.",
|
||||
"dataState": "PUT held pending; last loaded policy retained, edit controls disabled.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "button:has-text(\"Edit registration policy\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "saving",
|
||||
"label": "saving",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "click",
|
||||
"selector": "button:has-text(\"Edit registration policy\")"
|
||||
},
|
||||
{
|
||||
"action": "press",
|
||||
"selector": "input[type=\"checkbox\"]",
|
||||
"key": "Space"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": "button:has-text(\"Save settings\")"
|
||||
}
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "button:has-text(\"Saving…\")"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "admin-save-error",
|
||||
"label": "admin-save-error",
|
||||
"path": "/hosting/settings?fixture=admin-save-error",
|
||||
"goal": "Verify production shell UI projection, not server authorization.",
|
||||
"dataState": "Intentional PUT HTTP503; outcome unknown, retry blocked until reload.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "button:has-text(\"Edit registration policy\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "save-error",
|
||||
"label": "save-error",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "click",
|
||||
"selector": "button:has-text(\"Edit registration policy\")"
|
||||
},
|
||||
{
|
||||
"action": "press",
|
||||
"selector": "input[type=\"checkbox\"]",
|
||||
"key": "Space"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": "button:has-text(\"Save settings\")"
|
||||
}
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[role=\"alert\"]"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "account-off",
|
||||
"label": "account-off",
|
||||
"path": "/register?fixture=account-off",
|
||||
"goal": "Verify production shell UI projection, not server authorization.",
|
||||
"dataState": "Anonymous policy off; registration disabled and existing login link available.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "p:has-text(\"New account registration is disabled.\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "admin-account",
|
||||
"label": "admin-account",
|
||||
"path": "/account?fixture=admin-account",
|
||||
"goal": "Verify production shell UI projection, not server authorization.",
|
||||
"dataState": "Authenticated own account; addition available independently of registration policy. Global navigation remains on Workspace shell.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "button:has-text(\"Add passkey to alice\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ordinary-account-off",
|
||||
"label": "ordinary-account-off",
|
||||
"path": "/account?fixture=ordinary-account-off",
|
||||
"goal": "Verify production shell UI projection, not server authorization.",
|
||||
"dataState": "Authenticated own account; addition available independently of registration policy. Global navigation remains on Workspace shell.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "button:has-text(\"Add passkey to alice\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "account-error",
|
||||
"label": "account-error",
|
||||
"path": "/register?fixture=account-error",
|
||||
"goal": "Verify production shell UI projection, not server authorization.",
|
||||
"dataState": "Intentional policy HTTP503; expected console resource error and visible alert; login retained.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "button:has-text(\"Retry registration availability\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "account-loading",
|
||||
"label": "account-loading",
|
||||
"path": "/register?fixture=account-loading",
|
||||
"goal": "Verify production shell UI projection, not server authorization.",
|
||||
"dataState": "Policy request held pending; login remains available after session loads.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[role=\"status\"]:has-text(\"Checking registration availability\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"processes": [
|
||||
{
|
||||
"id": "hosting-ui-fixture",
|
||||
"command": "deno",
|
||||
"cwd": "../../..",
|
||||
"args": [
|
||||
"run",
|
||||
"--config",
|
||||
"tools/web-ux/deno.json",
|
||||
"--allow-read",
|
||||
"--allow-net",
|
||||
"web/workspace/test/hosting-browser/fixture-server.ts",
|
||||
"15378",
|
||||
"web/workspace/build"
|
||||
],
|
||||
"readyUrl": "http://127.0.0.1:15378/health"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,386 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "hosting-settings-light",
|
||||
"title": "Hosting settings: synthetic UI conditions on production shell",
|
||||
"baseUrl": "http://127.0.0.1:15378",
|
||||
"colorScheme": "light",
|
||||
"reducedMotion": "reduce",
|
||||
"personas": [
|
||||
{
|
||||
"id": "fixture",
|
||||
"label": "Synthetic API projection; no real authentication",
|
||||
"auth": {
|
||||
"kind": "anonymous"
|
||||
}
|
||||
}
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"label": "desktop",
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"label": "narrow",
|
||||
"width": 768,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"label": "mobile",
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"id": "admin-on",
|
||||
"label": "admin-on",
|
||||
"path": "/hosting/settings?fixture=admin-on",
|
||||
"goal": "Verify production shell UI projection, not server authorization.",
|
||||
"dataState": "Synthetic admin; policy on.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "button:has-text(\"Edit registration policy\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Loaded hosting-wide policy"
|
||||
},
|
||||
{
|
||||
"id": "edit",
|
||||
"label": "Keyboard edit focus",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "press",
|
||||
"selector": "button:has-text(\"Edit registration policy\")",
|
||||
"key": "Enter"
|
||||
}
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "input[type=\"checkbox\"]:focus"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "saved",
|
||||
"label": "Confirmed save",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "press",
|
||||
"selector": "input[type=\"checkbox\"]",
|
||||
"key": "Space"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": "button:has-text(\"Save settings\")"
|
||||
}
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[role=\"status\"]:has-text(\"Hosting settings saved.\")"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "admin-off",
|
||||
"label": "admin-off",
|
||||
"path": "/hosting/settings?fixture=admin-off",
|
||||
"goal": "Verify production shell UI projection, not server authorization.",
|
||||
"dataState": "Synthetic admin; policy off.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "button:has-text(\"Edit registration policy\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Loaded hosting-wide policy"
|
||||
},
|
||||
{
|
||||
"id": "edit",
|
||||
"label": "Keyboard edit focus",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "press",
|
||||
"selector": "button:has-text(\"Edit registration policy\")",
|
||||
"key": "Enter"
|
||||
}
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "input[type=\"checkbox\"]:focus"
|
||||
}
|
||||
},
|
||||
{
|
||||
"id": "saved",
|
||||
"label": "Confirmed save",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "press",
|
||||
"selector": "input[type=\"checkbox\"]",
|
||||
"key": "Space"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": "button:has-text(\"Save settings\")"
|
||||
}
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[role=\"status\"]:has-text(\"Hosting settings saved.\")"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ordinary",
|
||||
"label": "ordinary",
|
||||
"path": "/hosting/settings?fixture=ordinary",
|
||||
"goal": "Verify production shell UI projection, not server authorization.",
|
||||
"dataState": "Synthetic authenticated ordinary user, no admin controls.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "p:has-text(\"Hosting administrator access is required.\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "anonymous",
|
||||
"label": "anonymous",
|
||||
"path": "/hosting/settings?fixture=anonymous",
|
||||
"goal": "Verify production shell UI projection, not server authorization.",
|
||||
"dataState": "Synthetic anonymous visitor; sign-in guidance, no controls.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "a:has-text(\"Open Account\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "admin-loading",
|
||||
"label": "admin-loading",
|
||||
"path": "/hosting/settings?fixture=admin-loading",
|
||||
"goal": "Verify production shell UI projection, not server authorization.",
|
||||
"dataState": "Settings GET intentionally held pending; shell remains usable.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[role=\"status\"]:has-text(\"Loading hosting settings\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "admin-load-error",
|
||||
"label": "admin-load-error",
|
||||
"path": "/hosting/settings?fixture=admin-load-error",
|
||||
"goal": "Verify production shell UI projection, not server authorization.",
|
||||
"dataState": "Intentional HTTP503; expected console resource error and visible alert.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "button:has-text(\"Retry loading settings\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "admin-saving",
|
||||
"label": "admin-saving",
|
||||
"path": "/hosting/settings?fixture=admin-saving",
|
||||
"goal": "Verify production shell UI projection, not server authorization.",
|
||||
"dataState": "PUT held pending; last loaded policy retained, edit controls disabled.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "button:has-text(\"Edit registration policy\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "saving",
|
||||
"label": "saving",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "click",
|
||||
"selector": "button:has-text(\"Edit registration policy\")"
|
||||
},
|
||||
{
|
||||
"action": "press",
|
||||
"selector": "input[type=\"checkbox\"]",
|
||||
"key": "Space"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": "button:has-text(\"Save settings\")"
|
||||
}
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "button:has-text(\"Saving…\")"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "admin-save-error",
|
||||
"label": "admin-save-error",
|
||||
"path": "/hosting/settings?fixture=admin-save-error",
|
||||
"goal": "Verify production shell UI projection, not server authorization.",
|
||||
"dataState": "Intentional PUT HTTP503; outcome unknown, retry blocked until reload.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "button:has-text(\"Edit registration policy\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "save-error",
|
||||
"label": "save-error",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "click",
|
||||
"selector": "button:has-text(\"Edit registration policy\")"
|
||||
},
|
||||
{
|
||||
"action": "press",
|
||||
"selector": "input[type=\"checkbox\"]",
|
||||
"key": "Space"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": "button:has-text(\"Save settings\")"
|
||||
}
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[role=\"alert\"]"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "account-off",
|
||||
"label": "account-off",
|
||||
"path": "/register?fixture=account-off",
|
||||
"goal": "Verify production shell UI projection, not server authorization.",
|
||||
"dataState": "Anonymous policy off; registration disabled and existing login link available.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "p:has-text(\"New account registration is disabled.\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "admin-account",
|
||||
"label": "admin-account",
|
||||
"path": "/account?fixture=admin-account",
|
||||
"goal": "Verify production shell UI projection, not server authorization.",
|
||||
"dataState": "Authenticated own account; addition available independently of registration policy. Global navigation remains on Workspace shell.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "button:has-text(\"Add passkey to alice\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "ordinary-account-off",
|
||||
"label": "ordinary-account-off",
|
||||
"path": "/account?fixture=ordinary-account-off",
|
||||
"goal": "Verify production shell UI projection, not server authorization.",
|
||||
"dataState": "Authenticated own account; addition available independently of registration policy. Global navigation remains on Workspace shell.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "button:has-text(\"Add passkey to alice\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "account-error",
|
||||
"label": "account-error",
|
||||
"path": "/register?fixture=account-error",
|
||||
"goal": "Verify production shell UI projection, not server authorization.",
|
||||
"dataState": "Intentional policy HTTP503; expected console resource error and visible alert; login retained.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "button:has-text(\"Retry registration availability\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "account-loading",
|
||||
"label": "account-loading",
|
||||
"path": "/register?fixture=account-loading",
|
||||
"goal": "Verify production shell UI projection, not server authorization.",
|
||||
"dataState": "Policy request held pending; login remains available after session loads.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[role=\"status\"]:has-text(\"Checking registration availability\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"processes": [
|
||||
{
|
||||
"id": "hosting-ui-fixture",
|
||||
"command": "deno",
|
||||
"cwd": "../../..",
|
||||
"args": [
|
||||
"run",
|
||||
"--config",
|
||||
"tools/web-ux/deno.json",
|
||||
"--allow-read",
|
||||
"--allow-net",
|
||||
"web/workspace/test/hosting-browser/fixture-server.ts",
|
||||
"15378",
|
||||
"web/workspace/build"
|
||||
],
|
||||
"readyUrl": "http://127.0.0.1:15378/health"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "hosting-setup-dark",
|
||||
"title": "Hosting setup production UI: synthetic HTTP and navigator.credentials boundary only",
|
||||
"baseUrl": "http://127.0.0.1:15378",
|
||||
"colorScheme": "dark",
|
||||
"reducedMotion": "reduce",
|
||||
"personas": [
|
||||
{
|
||||
"id": "fixture",
|
||||
"label": "Synthetic API projection; no real authentication",
|
||||
"auth": {
|
||||
"kind": "anonymous"
|
||||
}
|
||||
}
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"label": "desktop",
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"label": "narrow",
|
||||
"width": 768,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"label": "mobile",
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"id": "setup-flow",
|
||||
"label": "Setup enrollment and login UI",
|
||||
"path": "/hosting/setup?fixture=setup-flow",
|
||||
"goal": "Separate host-token enrollment from explicit passkey login and verified settings access.",
|
||||
"dataState": "Synthetic actor, API and non-cryptographic navigator.credentials; no real token/authentication. Test-only bootstrap changes browser API boundary, not production UI modules/styles.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "#host-setup-token:enabled"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Empty password field; enrollment disabled",
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "host-help",
|
||||
"label": "Trusted-host instructions expanded",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "press",
|
||||
"selector": "summary",
|
||||
"key": "Enter"
|
||||
}
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "details[open]"
|
||||
},
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "masked-entry",
|
||||
"label": "Synthetic input password-masked; action enabled",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "press",
|
||||
"selector": "summary",
|
||||
"key": "Enter"
|
||||
},
|
||||
{
|
||||
"action": "fill",
|
||||
"selector": "#host-setup-token",
|
||||
"value": "${WEB_UX_SYNTHETIC_SETUP_INPUT}"
|
||||
}
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "button:has-text(\"Add administrator passkey\"):enabled"
|
||||
},
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "enrolled",
|
||||
"label": "Enrollment is not login; explicit login handoff",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "click",
|
||||
"selector": "button:has-text(\"Add administrator passkey\")"
|
||||
}
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "button:has-text(\"Sign in with new passkey\"):enabled"
|
||||
},
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "verified",
|
||||
"label": "Matching actor/session/access projected verified; settings link",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "press",
|
||||
"selector": "button:has-text(\"Sign in with new passkey\")",
|
||||
"key": "Enter"
|
||||
}
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "a:has-text(\"Open hosting settings\")"
|
||||
},
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "setup-reject",
|
||||
"label": "Rejected setup enrollment",
|
||||
"path": "/hosting/setup?fixture=setup-reject",
|
||||
"goal": "Show safe unconfirmed-enrollment guidance with empty cleared token and no settings action.",
|
||||
"dataState": "Intentional addition/options HTTP403. Expected console/resource diagnostics; no real authorization exercised.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "#host-setup-token:enabled"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "rejected",
|
||||
"label": "Rejected attempt; token cleared",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "fill",
|
||||
"selector": "#host-setup-token",
|
||||
"value": "${WEB_UX_SYNTHETIC_SETUP_INPUT}"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": "button:has-text(\"Add administrator passkey\")"
|
||||
}
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[role=\"alert\"]"
|
||||
},
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"processes": [
|
||||
{
|
||||
"id": "hosting-ui-fixture",
|
||||
"command": "deno",
|
||||
"cwd": "../../..",
|
||||
"args": [
|
||||
"run",
|
||||
"--config",
|
||||
"tools/web-ux/deno.json",
|
||||
"--allow-read",
|
||||
"--allow-net",
|
||||
"web/workspace/test/hosting-browser/fixture-server.ts",
|
||||
"15378",
|
||||
"web/workspace/build"
|
||||
],
|
||||
"readyUrl": "http://127.0.0.1:15378/health"
|
||||
}
|
||||
],
|
||||
"redact": {
|
||||
"text": [
|
||||
"${WEB_UX_SYNTHETIC_SETUP_INPUT}"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,178 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "hosting-setup-light",
|
||||
"title": "Hosting setup production UI: synthetic HTTP and navigator.credentials boundary only",
|
||||
"baseUrl": "http://127.0.0.1:15378",
|
||||
"colorScheme": "light",
|
||||
"reducedMotion": "reduce",
|
||||
"personas": [
|
||||
{
|
||||
"id": "fixture",
|
||||
"label": "Synthetic API projection; no real authentication",
|
||||
"auth": {
|
||||
"kind": "anonymous"
|
||||
}
|
||||
}
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"label": "desktop",
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"label": "narrow",
|
||||
"width": 768,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"label": "mobile",
|
||||
"width": 390,
|
||||
"height": 844
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"id": "setup-flow",
|
||||
"label": "Setup enrollment and login UI",
|
||||
"path": "/hosting/setup?fixture=setup-flow",
|
||||
"goal": "Separate host-token enrollment from explicit passkey login and verified settings access.",
|
||||
"dataState": "Synthetic actor, API and non-cryptographic navigator.credentials; no real token/authentication. Test-only bootstrap changes browser API boundary, not production UI modules/styles.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "#host-setup-token:enabled"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Empty password field; enrollment disabled",
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "host-help",
|
||||
"label": "Trusted-host instructions expanded",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "press",
|
||||
"selector": "summary",
|
||||
"key": "Enter"
|
||||
}
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "details[open]"
|
||||
},
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "masked-entry",
|
||||
"label": "Synthetic input password-masked; action enabled",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "press",
|
||||
"selector": "summary",
|
||||
"key": "Enter"
|
||||
},
|
||||
{
|
||||
"action": "fill",
|
||||
"selector": "#host-setup-token",
|
||||
"value": "${WEB_UX_SYNTHETIC_SETUP_INPUT}"
|
||||
}
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "button:has-text(\"Add administrator passkey\"):enabled"
|
||||
},
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "enrolled",
|
||||
"label": "Enrollment is not login; explicit login handoff",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "click",
|
||||
"selector": "button:has-text(\"Add administrator passkey\")"
|
||||
}
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "button:has-text(\"Sign in with new passkey\"):enabled"
|
||||
},
|
||||
"fullPage": true
|
||||
},
|
||||
{
|
||||
"id": "verified",
|
||||
"label": "Matching actor/session/access projected verified; settings link",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "press",
|
||||
"selector": "button:has-text(\"Sign in with new passkey\")",
|
||||
"key": "Enter"
|
||||
}
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "a:has-text(\"Open hosting settings\")"
|
||||
},
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "setup-reject",
|
||||
"label": "Rejected setup enrollment",
|
||||
"path": "/hosting/setup?fixture=setup-reject",
|
||||
"goal": "Show safe unconfirmed-enrollment guidance with empty cleared token and no settings action.",
|
||||
"dataState": "Intentional addition/options HTTP403. Expected console/resource diagnostics; no real authorization exercised.",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "#host-setup-token:enabled"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "rejected",
|
||||
"label": "Rejected attempt; token cleared",
|
||||
"interaction": [
|
||||
{
|
||||
"action": "fill",
|
||||
"selector": "#host-setup-token",
|
||||
"value": "${WEB_UX_SYNTHETIC_SETUP_INPUT}"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": "button:has-text(\"Add administrator passkey\")"
|
||||
}
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[role=\"alert\"]"
|
||||
},
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"processes": [
|
||||
{
|
||||
"id": "hosting-ui-fixture",
|
||||
"command": "deno",
|
||||
"cwd": "../../..",
|
||||
"args": [
|
||||
"run",
|
||||
"--config",
|
||||
"tools/web-ux/deno.json",
|
||||
"--allow-read",
|
||||
"--allow-net",
|
||||
"web/workspace/test/hosting-browser/fixture-server.ts",
|
||||
"15378",
|
||||
"web/workspace/build"
|
||||
],
|
||||
"readyUrl": "http://127.0.0.1:15378/health"
|
||||
}
|
||||
],
|
||||
"redact": {
|
||||
"text": [
|
||||
"${WEB_UX_SYNTHETIC_SETUP_INPUT}"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "public-entry-dark",
|
||||
"title": "Public entry and authenticated Workspace selection",
|
||||
"baseUrl": "http://127.0.0.1:15413",
|
||||
"colorScheme": "dark",
|
||||
"reducedMotion": "reduce",
|
||||
"personas": [
|
||||
{
|
||||
"id": "fixture",
|
||||
"label": "Synthetic HTTP projection; not a real authentication session",
|
||||
"auth": {
|
||||
"kind": "anonymous"
|
||||
}
|
||||
}
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"label": "desktop",
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"label": "narrow",
|
||||
"width": 768,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"label": "mobile",
|
||||
"width": 390,
|
||||
"height": 900
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"id": "anonymous",
|
||||
"label": "anonymous",
|
||||
"path": "/?fixture=anonymous",
|
||||
"goal": "Verify entry, session and Workspace authority remain distinct with reachable actions",
|
||||
"dataState": "Known anonymous; policy allowed; Workspace API would reject",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "a.entry-primary"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "policy-disabled",
|
||||
"label": "policy-disabled",
|
||||
"path": "/?fixture=policy-disabled",
|
||||
"goal": "Verify entry, session and Workspace authority remain distinct with reachable actions",
|
||||
"dataState": "Policy disabled; login retained",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "section p:has-text(\"registration is disabled\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "policy-error",
|
||||
"label": "policy-error",
|
||||
"path": "/?fixture=policy-error",
|
||||
"goal": "Verify entry, session and Workspace authority remain distinct with reachable actions",
|
||||
"dataState": "Intentional public policy HTTP503; login retained, no registration link",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "section p:has-text(\"availability is unknown\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "policy-loading",
|
||||
"label": "policy-loading",
|
||||
"path": "/?fixture=policy-loading",
|
||||
"goal": "Verify entry, session and Workspace authority remain distinct with reachable actions",
|
||||
"dataState": "Public policy held pending; login retained",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[role=status]:has-text(\"Checking registration\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "session-loading",
|
||||
"label": "session-loading",
|
||||
"path": "/?fixture=session-loading",
|
||||
"goal": "Verify entry, session and Workspace authority remain distinct with reachable actions",
|
||||
"dataState": "Whoami held pending; no anonymous or actor actions",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[role=status]:has-text(\"Loading session\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "session-error",
|
||||
"label": "session-error",
|
||||
"path": "/?fixture=session-error",
|
||||
"goal": "Verify entry, session and Workspace authority remain distinct with reachable actions",
|
||||
"dataState": "Intentional whoami HTTP503; unknown, not logged out",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[role=alert]"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "account",
|
||||
"label": "account",
|
||||
"path": "/?fixture=account",
|
||||
"goal": "Verify entry, session and Workspace authority remain distinct with reachable actions",
|
||||
"dataState": "Confirmed ordinary actor; no Workspace fetch or automatic navigation",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "a.entry-primary"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "admin",
|
||||
"label": "admin",
|
||||
"path": "/?fixture=admin",
|
||||
"goal": "Verify entry, session and Workspace authority remain distinct with reachable actions",
|
||||
"dataState": "Confirmed hosting admin grant independent of Workspace ownership",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "a[href=\"/hosting/settings\"]"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dashboard-zero",
|
||||
"label": "dashboard-zero",
|
||||
"path": "/dashboard?fixture=account",
|
||||
"goal": "Verify entry, session and Workspace authority remain distinct with reachable actions",
|
||||
"dataState": "Confirmed actor; zero Workspaces",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".workspace-empty-state"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dashboard-one",
|
||||
"label": "dashboard-one",
|
||||
"path": "/dashboard?fixture=workspace-one",
|
||||
"goal": "Verify entry, session and Workspace authority remain distinct with reachable actions",
|
||||
"dataState": "One Workspace requires explicit selection",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".workspace-catalog-card"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dashboard-many",
|
||||
"label": "dashboard-many",
|
||||
"path": "/dashboard?fixture=workspace-many",
|
||||
"goal": "Verify entry, session and Workspace authority remain distinct with reachable actions",
|
||||
"dataState": "Three Workspaces, long display name, explicit selection",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".workspace-catalog-card:nth-child(3)"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dashboard-error",
|
||||
"label": "dashboard-error",
|
||||
"path": "/dashboard?fixture=workspace-error",
|
||||
"goal": "Verify entry, session and Workspace authority remain distinct with reachable actions",
|
||||
"dataState": "Intentional catalog HTTP503; not empty; account retained",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".workspace-catalog-alert"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dashboard-loading",
|
||||
"label": "dashboard-loading",
|
||||
"path": "/dashboard?fixture=workspace-loading",
|
||||
"goal": "Verify entry, session and Workspace authority remain distinct with reachable actions",
|
||||
"dataState": "Catalog held pending; account remains available",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[role=status]:has-text(\"Loading Workspaces\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dashboard-anonymous",
|
||||
"label": "dashboard-anonymous",
|
||||
"path": "/dashboard?fixture=anonymous",
|
||||
"goal": "Verify entry, session and Workspace authority remain distinct with reachable actions",
|
||||
"dataState": "Known anonymous; login returnTo dashboard, no catalog fetch",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "a.entry-primary"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "create-denied",
|
||||
"label": "create-denied",
|
||||
"path": "/dashboard?fixture=creation-denied#workspace-create-title",
|
||||
"goal": "Verify entry, session and Workspace authority remain distinct with reachable actions",
|
||||
"dataState": "Intentional creation HTTP403; exact retry, no resources created",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".workspace-empty-state"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Creation denied",
|
||||
"fullPage": true,
|
||||
"interaction": [
|
||||
{
|
||||
"action": "fill",
|
||||
"selector": "input[autocomplete=off]",
|
||||
"value": "New Workspace"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": "button[type=submit]"
|
||||
}
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".workspace-catalog-alert"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "login-disabled",
|
||||
"label": "login-disabled",
|
||||
"path": "/login?fixture=policy-disabled",
|
||||
"goal": "Verify entry, session and Workspace authority remain distinct with reachable actions",
|
||||
"dataState": "Login remains usable while registration is disabled",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "nav p:has-text(\"registration is disabled\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"processes": [
|
||||
{
|
||||
"id": "workspace-fixture",
|
||||
"command": "deno",
|
||||
"cwd": "../../..",
|
||||
"args": [
|
||||
"run",
|
||||
"--allow-read",
|
||||
"--allow-net",
|
||||
"tools/web-ux/browser-tests/workspace_home_fixture_server.ts",
|
||||
"15414",
|
||||
"web/workspace/build"
|
||||
],
|
||||
"readyUrl": "http://127.0.0.1:15414/health"
|
||||
},
|
||||
{
|
||||
"id": "entry-fixture",
|
||||
"command": "deno",
|
||||
"cwd": "../../..",
|
||||
"args": [
|
||||
"run",
|
||||
"--config",
|
||||
"tools/web-ux/deno.json",
|
||||
"--allow-read",
|
||||
"--allow-net",
|
||||
"web/workspace/test/auth-browser/fixture-server.ts",
|
||||
"15413",
|
||||
"web/workspace/build",
|
||||
"http://127.0.0.1:15414"
|
||||
],
|
||||
"readyUrl": "http://127.0.0.1:15413/health"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
{
|
||||
"schemaVersion": 1,
|
||||
"id": "public-entry-light",
|
||||
"title": "Public entry and authenticated Workspace selection",
|
||||
"baseUrl": "http://127.0.0.1:15413",
|
||||
"colorScheme": "light",
|
||||
"reducedMotion": "reduce",
|
||||
"personas": [
|
||||
{
|
||||
"id": "fixture",
|
||||
"label": "Synthetic HTTP projection; not a real authentication session",
|
||||
"auth": {
|
||||
"kind": "anonymous"
|
||||
}
|
||||
}
|
||||
],
|
||||
"viewports": [
|
||||
{
|
||||
"label": "desktop",
|
||||
"width": 1440,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"label": "narrow",
|
||||
"width": 768,
|
||||
"height": 900
|
||||
},
|
||||
{
|
||||
"label": "mobile",
|
||||
"width": 390,
|
||||
"height": 900
|
||||
}
|
||||
],
|
||||
"routes": [
|
||||
{
|
||||
"id": "anonymous",
|
||||
"label": "anonymous",
|
||||
"path": "/?fixture=anonymous",
|
||||
"goal": "Verify entry, session and Workspace authority remain distinct with reachable actions",
|
||||
"dataState": "Known anonymous; policy allowed; Workspace API would reject",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "a.entry-primary"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "policy-disabled",
|
||||
"label": "policy-disabled",
|
||||
"path": "/?fixture=policy-disabled",
|
||||
"goal": "Verify entry, session and Workspace authority remain distinct with reachable actions",
|
||||
"dataState": "Policy disabled; login retained",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "section p:has-text(\"registration is disabled\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "policy-error",
|
||||
"label": "policy-error",
|
||||
"path": "/?fixture=policy-error",
|
||||
"goal": "Verify entry, session and Workspace authority remain distinct with reachable actions",
|
||||
"dataState": "Intentional public policy HTTP503; login retained, no registration link",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "section p:has-text(\"availability is unknown\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "policy-loading",
|
||||
"label": "policy-loading",
|
||||
"path": "/?fixture=policy-loading",
|
||||
"goal": "Verify entry, session and Workspace authority remain distinct with reachable actions",
|
||||
"dataState": "Public policy held pending; login retained",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[role=status]:has-text(\"Checking registration\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "session-loading",
|
||||
"label": "session-loading",
|
||||
"path": "/?fixture=session-loading",
|
||||
"goal": "Verify entry, session and Workspace authority remain distinct with reachable actions",
|
||||
"dataState": "Whoami held pending; no anonymous or actor actions",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[role=status]:has-text(\"Loading session\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "session-error",
|
||||
"label": "session-error",
|
||||
"path": "/?fixture=session-error",
|
||||
"goal": "Verify entry, session and Workspace authority remain distinct with reachable actions",
|
||||
"dataState": "Intentional whoami HTTP503; unknown, not logged out",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[role=alert]"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "account",
|
||||
"label": "account",
|
||||
"path": "/?fixture=account",
|
||||
"goal": "Verify entry, session and Workspace authority remain distinct with reachable actions",
|
||||
"dataState": "Confirmed ordinary actor; no Workspace fetch or automatic navigation",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "a.entry-primary"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "admin",
|
||||
"label": "admin",
|
||||
"path": "/?fixture=admin",
|
||||
"goal": "Verify entry, session and Workspace authority remain distinct with reachable actions",
|
||||
"dataState": "Confirmed hosting admin grant independent of Workspace ownership",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "a[href=\"/hosting/settings\"]"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dashboard-zero",
|
||||
"label": "dashboard-zero",
|
||||
"path": "/dashboard?fixture=account",
|
||||
"goal": "Verify entry, session and Workspace authority remain distinct with reachable actions",
|
||||
"dataState": "Confirmed actor; zero Workspaces",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".workspace-empty-state"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dashboard-one",
|
||||
"label": "dashboard-one",
|
||||
"path": "/dashboard?fixture=workspace-one",
|
||||
"goal": "Verify entry, session and Workspace authority remain distinct with reachable actions",
|
||||
"dataState": "One Workspace requires explicit selection",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".workspace-catalog-card"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dashboard-many",
|
||||
"label": "dashboard-many",
|
||||
"path": "/dashboard?fixture=workspace-many",
|
||||
"goal": "Verify entry, session and Workspace authority remain distinct with reachable actions",
|
||||
"dataState": "Three Workspaces, long display name, explicit selection",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".workspace-catalog-card:nth-child(3)"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dashboard-error",
|
||||
"label": "dashboard-error",
|
||||
"path": "/dashboard?fixture=workspace-error",
|
||||
"goal": "Verify entry, session and Workspace authority remain distinct with reachable actions",
|
||||
"dataState": "Intentional catalog HTTP503; not empty; account retained",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".workspace-catalog-alert"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dashboard-loading",
|
||||
"label": "dashboard-loading",
|
||||
"path": "/dashboard?fixture=workspace-loading",
|
||||
"goal": "Verify entry, session and Workspace authority remain distinct with reachable actions",
|
||||
"dataState": "Catalog held pending; account remains available",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "[role=status]:has-text(\"Loading Workspaces\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "dashboard-anonymous",
|
||||
"label": "dashboard-anonymous",
|
||||
"path": "/dashboard?fixture=anonymous",
|
||||
"goal": "Verify entry, session and Workspace authority remain distinct with reachable actions",
|
||||
"dataState": "Known anonymous; login returnTo dashboard, no catalog fetch",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "a.entry-primary"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "create-denied",
|
||||
"label": "create-denied",
|
||||
"path": "/dashboard?fixture=creation-denied#workspace-create-title",
|
||||
"goal": "Verify entry, session and Workspace authority remain distinct with reachable actions",
|
||||
"dataState": "Intentional creation HTTP403; exact retry, no resources created",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".workspace-empty-state"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Creation denied",
|
||||
"fullPage": true,
|
||||
"interaction": [
|
||||
{
|
||||
"action": "fill",
|
||||
"selector": "input[autocomplete=off]",
|
||||
"value": "New Workspace"
|
||||
},
|
||||
{
|
||||
"action": "click",
|
||||
"selector": "button[type=submit]"
|
||||
}
|
||||
],
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": ".workspace-catalog-alert"
|
||||
}
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "login-disabled",
|
||||
"label": "login-disabled",
|
||||
"path": "/login?fixture=policy-disabled",
|
||||
"goal": "Verify entry, session and Workspace authority remain distinct with reachable actions",
|
||||
"dataState": "Login remains usable while registration is disabled",
|
||||
"ready": {
|
||||
"kind": "selector",
|
||||
"selector": "nav p:has-text(\"registration is disabled\")"
|
||||
},
|
||||
"capturePoints": [
|
||||
{
|
||||
"id": "initial",
|
||||
"label": "Initial state",
|
||||
"fullPage": true
|
||||
}
|
||||
]
|
||||
}
|
||||
],
|
||||
"processes": [
|
||||
{
|
||||
"id": "workspace-fixture",
|
||||
"command": "deno",
|
||||
"cwd": "../../..",
|
||||
"args": [
|
||||
"run",
|
||||
"--allow-read",
|
||||
"--allow-net",
|
||||
"tools/web-ux/browser-tests/workspace_home_fixture_server.ts",
|
||||
"15414",
|
||||
"web/workspace/build"
|
||||
],
|
||||
"readyUrl": "http://127.0.0.1:15414/health"
|
||||
},
|
||||
{
|
||||
"id": "entry-fixture",
|
||||
"command": "deno",
|
||||
"cwd": "../../..",
|
||||
"args": [
|
||||
"run",
|
||||
"--config",
|
||||
"tools/web-ux/deno.json",
|
||||
"--allow-read",
|
||||
"--allow-net",
|
||||
"web/workspace/test/auth-browser/fixture-server.ts",
|
||||
"15413",
|
||||
"web/workspace/build",
|
||||
"http://127.0.0.1:15414"
|
||||
],
|
||||
"readyUrl": "http://127.0.0.1:15413/health"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -6,8 +6,10 @@
|
||||
"dev": "deno run -A npm:vite@8.3.2 dev",
|
||||
"dev:backend": "cd ../.. && cargo run -p yoi-workspace-server --bin yoi-server -- serve --listen 127.0.0.1:8787",
|
||||
"check": "deno run -A npm:@sveltejs/kit@3.0.0 sync && deno run -A npm:svelte-check@4.7.6 --tsconfig ./tsconfig.json",
|
||||
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts",
|
||||
"test": "deno test --allow-read=src,test,tests,../../docs,../../resources/config-schema --allow-env=LOG,VSCODE_TEXTMATE_DEBUG,NODE_ENV tests/workspace-model.test.ts tests/repository-api.test.ts tests/workspace-catalog.test.ts tests/profile-api.test.ts tests/skill-api.test.ts tests/tooltip-contract.test.ts tests/bevel-contract.test.ts tests/bevel-line-contract.test.ts tests/typography-contract.test.ts src/lib/workspace/navigation/deferred.test.ts src/lib/workspace/auth/model.test.ts src/lib/workspace/auth/routes.test.ts src/lib/workspace/auth/hosting.test.ts src/lib/workspace/auth/hosting-enrollment.test.ts src/lib/workspace/auth/activation.test.ts src/lib/workspace/auth/hosting-accounts.test.ts src/lib/workspace/protocol-validator.test.ts tests/auth-api.test.ts src/lib/workspace/api/http.test.ts src/lib/workspace/api/workers.test.ts src/lib/workspace/api/ticket-browser.test.ts src/lib/workspace/api/ticket-case.test.ts src/lib/workspace/api/ticket-operations.test.ts src/lib/workspace/header/breadcrumb-model.test.ts src/lib/workspace/console/chat-submit.test.ts src/lib/workspace/console/composer-delivery.test.ts test/composer-history.test.ts tests/composer-paste.test.ts src/lib/workspace/console/composer-command.test.ts src/lib/workspace/console/composer-draft.test.ts src/lib/workspace/console/composer-completion.test.ts src/lib/workspace/console/composer-invocation.test.ts src/lib/workspace/console/file-completions.test.ts test/console/ansi.test.ts src/lib/workspace/console/history.test.ts src/lib/workspace/console/model.test.ts src/lib/workspace/console/alert-order.test.ts src/lib/workspace/console/turn-navigation.test.ts src/lib/workspace/console/run-status.test.ts src/lib/workspace/companion/api.test.ts tests/workdir-api.test.ts src/lib/workspace/settings/workdir-checkout.test.ts tests/workdir-removal.test.ts tests/workdir-permissions.test.ts src/lib/workspace/console/tasks.test.ts test/ticket-detail-route-reuse.test.ts test/spa-shell.test.ts test/objective-pages.test.ts test/repositories/ui.test.ts src/lib/workspace/console/worker-console.ui.test.ts src/lib/workspace/settings/model.test.ts tests/runtime-connection.test.ts tests/runtime-management.test.ts tests/runtime-management-source.test.ts src/lib/workspace/sidebar/override-stack.test.ts src/lib/workspace/sidebar/route-ownership.test.ts src/lib/workspace/sidebar/workers.test.ts src/lib/workspace/sidebar/workspace-switcher.test.ts src/lib/workspace/sidebar/worker-subscription.test.ts src/lib/workspace/sidebar/worker-workdir-meta.test.ts src/lib/workspace/sidebar/worker-launch.test.ts src/lib/workspace/workers/worker-launch.test.ts tests/runtime-workers.test.ts test/sidebar/worker-actions.test.ts src/lib/workspace/tickets/merge-request-resources.test.ts src/lib/workspace/tickets/ticket-panel.test.ts src/lib/workspace/tickets/ticket-search.test.ts test/merge-request-status.test.ts test/config-source/decodal-grammar.test.ts test/config-source/editor-state.test.ts test/config-source/fixed-schema-wrapper.test.ts test/config-source/toolchain.test.ts test/config-source/wasm-parity.test.ts test/config-source/api.test.ts tests/memory-api.test.ts test/repository-access/api.test.ts test/repository-access/loader.test.ts test/repository-access/model.test.ts test/repository-access/structure.test.ts test/repository-ssh-connection-ui.test.ts src/lib/workspace/drive/api.test.ts src/lib/workspace/drive/controller.test.ts src/lib/workspace/drive-ui/policy.test.ts",
|
||||
"test:component": "deno run -A npm:vitest@5.0.3 run --config vitest.config.ts",
|
||||
"test:browser:activation": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/activation-browser/activation-shell.browser.test.ts",
|
||||
"test:browser:hosting": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/hosting-browser/hosting-shell.browser.test.ts test/hosting-browser/setup-shell.browser.test.ts",
|
||||
"test:browser:empty-workspace": "deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/visual-review/empty-workspace.browser.test.ts",
|
||||
"test:browser:drive": "deno test --config test/drive-browser/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/drive-browser/drive.browser.test.ts",
|
||||
"test:drive:real-api": "cd ../.. && cargo test -p yoi-workspace-server --lib drive_web_roundtrip_tests -- --ignored",
|
||||
@@ -16,7 +18,9 @@
|
||||
"generate:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types",
|
||||
"check:repository-api": "cd ../.. && cargo run -q -p server-api --example generate_repository_openapi_types -- --check",
|
||||
"generate:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types",
|
||||
"check:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types -- --check"
|
||||
"check:ticket-api": "cd ../.. && cargo run -q -p server-api --example generate_ticket_api_types -- --check",
|
||||
"test:browser:auth": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/auth-browser/auth-shell.browser.test.ts",
|
||||
"test:browser:entry": "mkdir -p ../../target/web-ux/tmp && TMPDIR=../../target/web-ux/tmp deno test --config ../../tools/web-ux/deno.json --allow-env --allow-net --allow-read --allow-write --allow-run --allow-sys test/auth-browser/entry-shell.browser.test.ts"
|
||||
},
|
||||
"imports": {
|
||||
"@sveltejs/adapter-static": "npm:@sveltejs/adapter-static@4.0.0",
|
||||
|
||||
@@ -3,6 +3,16 @@
|
||||
|
||||
export type AuthPublicConfig = { rp_id: string, origin: string, public_base_url: string, cookie_name: string, };
|
||||
|
||||
export type HostingSettings = { self_registration_enabled: boolean, };
|
||||
|
||||
export type HostingAccess = { is_admin: boolean, };
|
||||
|
||||
export type ProvisionalAccountRequest = { handle: string, };
|
||||
|
||||
export type ActivationCodeResponse = { user: AuthenticatedUser, code: string, expires_at: string, };
|
||||
|
||||
export type ActivationOptionsRequest = { handle: string, code: string, browser_origin?: string | null, };
|
||||
|
||||
export type ActorAuthMethod = "browser_session" | "api_token";
|
||||
|
||||
export type AuthenticatedUser = { user_id: string, account_id: string, handle: string, display_name: string, };
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-companion-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:21dfcde7b03c0b08f9ede1012962ff69a05d4f8373d5a9c5abe6ab43ba5c3720)
|
||||
// Input contract: 0.1.0 (sha256:d6241af8f70efb7be45195dc27c01e36517d13484cd38f71490ce5d253dd84e8)
|
||||
// Options: companion-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/companion-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_companion_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-repository-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:21dfcde7b03c0b08f9ede1012962ff69a05d4f8373d5a9c5abe6ab43ba5c3720)
|
||||
// Input contract: 0.1.0 (sha256:d6241af8f70efb7be45195dc27c01e36517d13484cd38f71490ce5d253dd84e8)
|
||||
// Options: repository-operation-schema-closure,openapi-3.1,format:uint64=safe-number,strict-safe-integers,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/repository-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_repository_openapi_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-runtime-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:21dfcde7b03c0b08f9ede1012962ff69a05d4f8373d5a9c5abe6ab43ba5c3720)
|
||||
// Input contract: 0.1.0 (sha256:d6241af8f70efb7be45195dc27c01e36517d13484cd38f71490ce5d253dd84e8)
|
||||
// Options: runtime-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/runtime-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_runtime_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-ticket-browser-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:21dfcde7b03c0b08f9ede1012962ff69a05d4f8373d5a9c5abe6ab43ba5c3720)
|
||||
// Input contract: 0.1.0 (sha256:d6241af8f70efb7be45195dc27c01e36517d13484cd38f71490ce5d253dd84e8)
|
||||
// Options: ticket-objective-merge-request-browser-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/ticket-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_ticket_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-workdir-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:21dfcde7b03c0b08f9ede1012962ff69a05d4f8373d5a9c5abe6ab43ba5c3720)
|
||||
// Input contract: 0.1.0 (sha256:92f1f789f68d3c5d55c61d9bcf7d66451d4492cd0007aa178da1472af103719a)
|
||||
// Options: workdir-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/workdir-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_workdir_api_types -- --check
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
// @generated by yoi-worker-openapi-typescript@1. Do not edit.
|
||||
// Input: openapi/server-api.json
|
||||
// Input contract: 0.1.0 (sha256:21dfcde7b03c0b08f9ede1012962ff69a05d4f8373d5a9c5abe6ab43ba5c3720)
|
||||
// Input contract: 0.1.0 (sha256:d6241af8f70efb7be45195dc27c01e36517d13484cd38f71490ce5d253dd84e8)
|
||||
// Options: worker-operation-schema-closure,openapi-3.1,strict-integer-ranges,sorted-output
|
||||
// Output: web/workspace/src/lib/generated/worker-launch-api.ts
|
||||
// Regenerate/check: cargo run -q -p server-api --example generate_worker_launch_api_types -- --check
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { loadHostingAccess } from '#lib/workspace/auth/hosting.ts';
|
||||
let isAdmin = $state(false);
|
||||
// 親の確認済みsessionが変わるとこのcomponentも破棄する。Workspace所有から管理権限を推測しない。
|
||||
onMount(() => {
|
||||
let disposed = false;
|
||||
void loadHostingAccess().then((access) => { if (!disposed) isAdmin = access.is_admin; }).catch(() => {});
|
||||
return () => { disposed = true; };
|
||||
});
|
||||
</script>
|
||||
|
||||
<nav aria-label="Your account">
|
||||
<a href="/account">Account</a>
|
||||
{#if isAdmin}<a href="/hosting/settings">Hosting settings</a>{/if}
|
||||
</nav>
|
||||
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { goto } from '$app/navigation';
|
||||
import { page } from '$app/state';
|
||||
import { loginWithPasskey, registerPasskey } from './api.ts';
|
||||
import { BrowserAuth } from './browser-session.svelte.ts';
|
||||
import { loadRegistrationPolicy } from './hosting.ts';
|
||||
import { authEntryUrl, safeReturnTo, AUTHENTICATED_HOME } from './routes.ts';
|
||||
import RegistrationLink from './RegistrationLink.svelte';
|
||||
import './auth-page.css';
|
||||
|
||||
let { mode }: { mode: 'login' | 'register' } = $props();
|
||||
const auth = new BrowserAuth();
|
||||
let handle = $state('');
|
||||
let displayName = $state('');
|
||||
let policy = $state<boolean | null>(null);
|
||||
let policyLoading = $state(true);
|
||||
let policyError = $state('');
|
||||
let policyRequest = 0;
|
||||
const destination = $derived(safeReturnTo(page.url.searchParams.get('returnTo')));
|
||||
|
||||
async function refreshPolicy() {
|
||||
if (mode !== 'register') return;
|
||||
const request = ++policyRequest;
|
||||
policyLoading = true; policy = null; policyError = '';
|
||||
try {
|
||||
const result = await loadRegistrationPolicy();
|
||||
if (request === policyRequest) policy = result.self_registration_enabled;
|
||||
} catch {
|
||||
if (request === policyRequest) policyError = 'Registration availability could not be loaded.';
|
||||
} finally {
|
||||
if (request === policyRequest) policyLoading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submit() {
|
||||
if (auth.busy || auth.session.kind !== 'ready' || auth.actor || !handle.trim() ||
|
||||
(mode === 'register' && (policy !== true || !displayName.trim()))) return;
|
||||
const registering = mode === 'register';
|
||||
const id = handle.trim();
|
||||
const name = displayName.trim();
|
||||
const confirmed = await auth.run(
|
||||
registering ? 'Waiting for new account registration…' : 'Waiting for passkey login…',
|
||||
registering ? 'New account registered.' : 'Logged in.',
|
||||
(signal) => registering ? registerPasskey(id, name, signal) : loginWithPasskey(id, signal),
|
||||
);
|
||||
if (confirmed && auth.actor) {
|
||||
await goto(destination, { replaceState: true });
|
||||
} else {
|
||||
if (confirmed) {
|
||||
auth.status = '';
|
||||
auth.error = 'A normal browser session was not confirmed. Log in again.';
|
||||
}
|
||||
if (registering) void refreshPolicy();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void auth.refresh();
|
||||
void refreshPolicy();
|
||||
window.addEventListener('focus', refreshPolicy);
|
||||
return () => { ++policyRequest; auth.dispose(); window.removeEventListener('focus', refreshPolicy); };
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head><title>{mode === 'login' ? 'Log in' : 'Create account'} · Yoi</title></svelte:head>
|
||||
<div class="auth-page" data-sveltekit-preload-data="off">
|
||||
<h1>{mode === 'login' ? 'Log in' : 'Create account'}</h1>
|
||||
{#if auth.session.kind === 'loading'}
|
||||
<p role="status" class="muted">Loading session…</p>
|
||||
{:else if auth.session.kind === 'error'}
|
||||
<p role="alert" class="error">Your session could not be loaded. Login status is unknown.</p>
|
||||
<button type="button" disabled={auth.busy} onclick={() => auth.refresh()}>Retry session</button>
|
||||
{:else if auth.actor}
|
||||
<p>Logged in as <strong>{auth.actor.display_name}</strong> <span class="muted">@{auth.actor.handle}</span>.</p>
|
||||
<a href={destination}>Continue to {destination === AUTHENTICATED_HOME ? 'dashboard / Workspaces' : 'requested page'}</a>
|
||||
<a href="/account">Open Account</a>
|
||||
{:else}
|
||||
{#if mode === 'register'}
|
||||
{#if policyLoading}
|
||||
<p role="status" class="muted">Checking registration availability…</p>
|
||||
{:else if policyError}
|
||||
<p role="alert" class="error">{policyError}</p>
|
||||
<button type="button" onclick={refreshPolicy}>Retry registration availability</button>
|
||||
{:else if policy === false}
|
||||
<p>New account registration is disabled. Existing users can still log in.</p>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if mode === 'login' || policy === true}
|
||||
<form onsubmit={(event) => { event.preventDefault(); void submit(); }}>
|
||||
<label>Account ID (handle)
|
||||
<input bind:value={handle} required maxlength="128" autocomplete="username webauthn" autocapitalize="off" spellcheck="false" disabled={auth.busy} aria-describedby="id-help" />
|
||||
</label>
|
||||
<p id="id-help" class="muted">{mode === 'login' ? 'Use your account ID, not your display name.' : 'Your unique sign-in ID. This is separate from your display name.'}</p>
|
||||
{#if mode === 'register'}
|
||||
<label>Display name
|
||||
<input bind:value={displayName} required maxlength="128" autocomplete="name" disabled={auth.busy} />
|
||||
</label>
|
||||
{/if}
|
||||
<button class="primary" type="submit" disabled={auth.busy || !handle.trim() || (mode === 'register' && !displayName.trim())}>{mode === 'login' ? 'Log in with passkey' : 'Register new account'}</button>
|
||||
</form>
|
||||
{/if}
|
||||
{/if}
|
||||
{#if auth.status}<p role="status">{auth.status}</p>{/if}
|
||||
{#if auth.error}<p role="alert" class="error">{auth.error}</p>{/if}
|
||||
<nav aria-label="Authentication">
|
||||
{#if mode === 'login' && auth.session.kind === 'ready' && !auth.actor}
|
||||
<RegistrationLink returnTo={destination} />
|
||||
{:else if mode === 'register'}
|
||||
<a href={authEntryUrl('/login', destination)}>Log in with an existing account</a>
|
||||
{/if}
|
||||
<a href="/activate">Activate an account with a code</a>
|
||||
<a href={AUTHENTICATED_HOME}>Dashboard / Workspaces</a>
|
||||
<a href="/">Yoi home</a>
|
||||
</nav>
|
||||
</div>
|
||||
@@ -0,0 +1,40 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { loadRegistrationPolicy, SESSION_CHANGED_EVENT } from './hosting.ts';
|
||||
import { authEntryUrl, AUTHENTICATED_HOME } from './routes.ts';
|
||||
|
||||
let { returnTo = AUTHENTICATED_HOME }: { returnTo?: string } = $props();
|
||||
let policy = $state<'loading' | 'error' | 'enabled' | 'disabled'>('loading');
|
||||
let request = 0;
|
||||
async function refresh() {
|
||||
const current = ++request;
|
||||
policy = 'loading';
|
||||
try {
|
||||
const result = await loadRegistrationPolicy();
|
||||
if (current === request) policy = result.self_registration_enabled ? 'enabled' : 'disabled';
|
||||
} catch {
|
||||
if (current === request) policy = 'error';
|
||||
}
|
||||
}
|
||||
onMount(() => {
|
||||
void refresh();
|
||||
window.addEventListener('focus', refresh);
|
||||
window.addEventListener(SESSION_CHANGED_EVENT, refresh);
|
||||
return () => {
|
||||
++request;
|
||||
window.removeEventListener('focus', refresh);
|
||||
window.removeEventListener(SESSION_CHANGED_EVENT, refresh);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if policy === 'enabled'}
|
||||
<a href={authEntryUrl('/register', returnTo)}>Create a new account</a>
|
||||
{:else if policy === 'disabled'}
|
||||
<p class="muted">New account registration is disabled. Existing users can still log in.</p>
|
||||
{:else if policy === 'loading'}
|
||||
<p class="muted" role="status">Checking registration availability…</p>
|
||||
{:else}
|
||||
<p class="muted">Registration availability is unknown. Existing users can still log in.</p>
|
||||
<button type="button" onclick={refresh}>Retry registration availability</button>
|
||||
{/if}
|
||||
@@ -0,0 +1,175 @@
|
||||
declare const Deno: { test(name: string, fn: () => Promise<void>): void };
|
||||
import { activateWithPasskey } from "./activation.ts";
|
||||
import {
|
||||
authUser,
|
||||
credentialFixture,
|
||||
registrationOptions,
|
||||
} from "./api.test-fixtures.ts";
|
||||
function equal(a: unknown, b: unknown) {
|
||||
if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error("Values differ");
|
||||
}
|
||||
async function rejects(run: Promise<unknown>) {
|
||||
try {
|
||||
await run;
|
||||
} catch (error) {
|
||||
equal((error as Error).message, "Activation could not be confirmed.");
|
||||
return;
|
||||
}
|
||||
throw new Error("Expected rejection");
|
||||
}
|
||||
async function boundary(
|
||||
fetcher: typeof fetch,
|
||||
create: (options: CredentialCreationOptions) => Promise<Credential | null>,
|
||||
run: () => Promise<void>,
|
||||
) {
|
||||
const saved = ["fetch", "window", "navigator"].map((key) =>
|
||||
[key, Object.getOwnPropertyDescriptor(globalThis, key)] as const
|
||||
);
|
||||
Object.defineProperties(globalThis, {
|
||||
fetch: { configurable: true, value: fetcher },
|
||||
window: {
|
||||
configurable: true,
|
||||
value: {
|
||||
isSecureContext: true,
|
||||
location: { origin: "https://example.test" },
|
||||
},
|
||||
},
|
||||
navigator: { configurable: true, value: { credentials: { create } } },
|
||||
});
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
for (const [key, descriptor] of saved) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
// HTTP/WebAuthn境界の代替。本物のServerによるcode検証やcredential保存の試験ではない。
|
||||
Deno.test("activation uses only dedicated endpoints and confirms normalized target after completion", async () => {
|
||||
const requests: { path: string; init: RequestInit }[] = [];
|
||||
const signal = new AbortController().signal;
|
||||
await boundary(async (input, init) => {
|
||||
requests.push({ path: String(input), init: init! });
|
||||
return Response.json(
|
||||
String(input).endsWith("/options")
|
||||
? registrationOptions
|
||||
: { user: authUser },
|
||||
);
|
||||
}, async (options) => {
|
||||
equal(options.signal === signal, true);
|
||||
return credentialFixture();
|
||||
}, async () => {
|
||||
equal(
|
||||
await activateWithPasskey(" ALIce ", "synthetic-input-only", signal),
|
||||
authUser,
|
||||
);
|
||||
equal(requests.map((r) => r.path), [
|
||||
"/api/auth/activation/options",
|
||||
"/api/auth/activation/complete",
|
||||
]);
|
||||
for (const { init } of requests) {
|
||||
equal(init.method, "POST");
|
||||
equal(init.credentials, "same-origin");
|
||||
equal(init.redirect, "error");
|
||||
equal(init.mode, "same-origin");
|
||||
equal(init.cache, "no-store");
|
||||
equal(init.referrerPolicy, "no-referrer");
|
||||
equal(new Headers(init.headers).has("authorization"), false);
|
||||
}
|
||||
equal(JSON.parse(String(requests[0].init.body)), {
|
||||
handle: "alice",
|
||||
code: "synthetic-input-only",
|
||||
browser_origin: "https://example.test",
|
||||
});
|
||||
equal(Object.keys(JSON.parse(String(requests[1].init.body))).sort(), [
|
||||
"challenge_id",
|
||||
"credential",
|
||||
]);
|
||||
equal(
|
||||
String(requests[1].init.body).includes("synthetic-input-only"),
|
||||
false,
|
||||
);
|
||||
});
|
||||
});
|
||||
Deno.test("activation cancellation and abort never send complete or return active", async () => {
|
||||
for (const mode of ["cancel", "abort", "null"] as const) {
|
||||
let calls = 0;
|
||||
const controller = new AbortController();
|
||||
await boundary(async () => {
|
||||
calls++;
|
||||
return Response.json(registrationOptions);
|
||||
}, async () => {
|
||||
if (mode === "cancel") throw new Error("synthetic-input-only");
|
||||
if (mode === "abort") controller.abort();
|
||||
return mode === "null" ? null : credentialFixture();
|
||||
}, async () => {
|
||||
await rejects(
|
||||
activateWithPasskey("alice", "synthetic-input-only", controller.signal),
|
||||
);
|
||||
equal(calls, 1);
|
||||
});
|
||||
}
|
||||
});
|
||||
Deno.test("activation sanitizes HTTP transport malformed and mismatched completion without retry", async () => {
|
||||
for (const mode of ["http", "throw", "malformed", "mismatch"] as const) {
|
||||
let calls = 0;
|
||||
await boundary(
|
||||
async (input) => {
|
||||
calls++;
|
||||
if (mode === "throw") throw new Error("synthetic-input-only");
|
||||
if (mode === "http") {
|
||||
return new Response("synthetic-input-only", { status: 403 });
|
||||
}
|
||||
if (mode === "malformed") {
|
||||
return Response.json({ unexpected: "synthetic-input-only" });
|
||||
}
|
||||
return Response.json(
|
||||
String(input).endsWith("/options")
|
||||
? registrationOptions
|
||||
: { user: { ...authUser, handle: "other" } },
|
||||
);
|
||||
},
|
||||
async () => credentialFixture(),
|
||||
async () => {
|
||||
await rejects(
|
||||
activateWithPasskey(
|
||||
"alice",
|
||||
"synthetic-input-only",
|
||||
new AbortController().signal,
|
||||
),
|
||||
);
|
||||
equal(calls, mode === "mismatch" ? 2 : 1);
|
||||
},
|
||||
);
|
||||
}
|
||||
});
|
||||
Deno.test("activation invalid IDs and oversized code are rejected before network", async () => {
|
||||
let calls = 0;
|
||||
await boundary(
|
||||
async () => {
|
||||
calls++;
|
||||
return Response.json({});
|
||||
},
|
||||
async () => credentialFixture(),
|
||||
async () => {
|
||||
for (const handle of ["", "a/b", "A", "i̇", "a".repeat(65)]) {
|
||||
await rejects(
|
||||
activateWithPasskey(
|
||||
handle,
|
||||
"synthetic-input-only",
|
||||
new AbortController().signal,
|
||||
),
|
||||
);
|
||||
}
|
||||
await rejects(
|
||||
activateWithPasskey(
|
||||
"alice",
|
||||
"x".repeat(4097),
|
||||
new AbortController().signal,
|
||||
),
|
||||
);
|
||||
equal(calls, 0);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,79 @@
|
||||
import type {
|
||||
ActivationOptionsRequest,
|
||||
PasskeyRegistrationCompleteRequest,
|
||||
} from "#lib/generated/auth-api.ts";
|
||||
import { readBoundedAuthResponseJson } from "./api.ts";
|
||||
import { normalizeActivationHandle } from "./hosting-accounts.ts";
|
||||
import {
|
||||
type AuthUser,
|
||||
parseAuthUserResponse,
|
||||
prepareRegistrationOptions,
|
||||
registrationCredentialToJson,
|
||||
} from "./model.ts";
|
||||
|
||||
// 一時codeは通常auth clientや本人の鍵追加APIへ渡さない。options取得はsessionではない。
|
||||
// 再開はID+codeの再入力から行い、ceremony/codeをURL・storageへ保存しない。
|
||||
// Cookieが存在しても対象はServerがcodeから決め、complete成功時だけ新sessionを受け取る。
|
||||
export async function activateWithPasskey(
|
||||
handle: string,
|
||||
code: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<AuthUser> {
|
||||
try {
|
||||
handle = normalizeActivationHandle(handle);
|
||||
if (window.isSecureContext !== true || !code || code.length > 4096) {
|
||||
throw new Error("Activation unavailable");
|
||||
}
|
||||
const request = async (
|
||||
path: "/api/auth/activation/options" | "/api/auth/activation/complete",
|
||||
body: ActivationOptionsRequest | PasskeyRegistrationCompleteRequest,
|
||||
): Promise<unknown> => {
|
||||
signal.throwIfAborted();
|
||||
const response = await fetch(path, {
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
mode: "same-origin",
|
||||
redirect: "error",
|
||||
cache: "no-store",
|
||||
referrerPolicy: "no-referrer",
|
||||
signal,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new Error("Activation rejected");
|
||||
}
|
||||
return await readBoundedAuthResponseJson(response);
|
||||
};
|
||||
const options = await request("/api/auth/activation/options", {
|
||||
handle,
|
||||
code,
|
||||
browser_origin: window.location.origin,
|
||||
});
|
||||
code = "";
|
||||
const publicKey = prepareRegistrationOptions(options);
|
||||
const challengeId = (options as { challenge_id: string }).challenge_id;
|
||||
signal.throwIfAborted();
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey,
|
||||
signal,
|
||||
});
|
||||
if (!credential) throw new Error("Registration cancelled");
|
||||
signal.throwIfAborted();
|
||||
const result = parseAuthUserResponse(
|
||||
await request("/api/auth/activation/complete", {
|
||||
challenge_id: challengeId,
|
||||
credential: registrationCredentialToJson(credential),
|
||||
}),
|
||||
).user;
|
||||
signal.throwIfAborted();
|
||||
if (result.handle !== handle) throw new Error("Activation target mismatch");
|
||||
return result;
|
||||
} catch {
|
||||
// HTTP/WebAuthn/JSON例外はsecretを含む可能性があるため、本文も例外も転載しない。
|
||||
throw new Error("Activation could not be confirmed.");
|
||||
} finally {
|
||||
code = "";
|
||||
}
|
||||
}
|
||||
@@ -20,6 +20,12 @@ import {
|
||||
type WhoamiResponse,
|
||||
} from "#lib/workspace/auth/model.ts";
|
||||
|
||||
export class AuthRequestError extends Error {
|
||||
constructor(readonly status: number) {
|
||||
super(`Auth request failed (${status}).`);
|
||||
}
|
||||
}
|
||||
|
||||
const MAX_AUTH_RESPONSE_BYTES = 256 * 1024;
|
||||
|
||||
export async function readBoundedAuthResponseJson(
|
||||
@@ -80,6 +86,9 @@ export async function readBoundedAuthResponseJson(
|
||||
async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
|
||||
const response = await fetch(path, {
|
||||
credentials: "same-origin",
|
||||
mode: "same-origin",
|
||||
redirect: "error",
|
||||
cache: "no-store",
|
||||
...init,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
@@ -88,20 +97,23 @@ async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new Error(`Auth request failed (${response.status}).`);
|
||||
throw new AuthRequestError(response.status);
|
||||
}
|
||||
return await readBoundedAuthResponseJson(response);
|
||||
}
|
||||
|
||||
export async function loadWhoami(): Promise<WhoamiResponse> {
|
||||
return parseWhoamiResponse(await requestJson("/api/auth/whoami"));
|
||||
export async function loadWhoami(
|
||||
signal?: AbortSignal,
|
||||
): Promise<WhoamiResponse> {
|
||||
return parseWhoamiResponse(await requestJson("/api/auth/whoami", { signal }));
|
||||
}
|
||||
|
||||
export async function logout(): Promise<void> {
|
||||
export async function logout(signal?: AbortSignal): Promise<void> {
|
||||
parseLogoutResponse(
|
||||
await requestJson("/api/auth/logout", {
|
||||
method: "POST",
|
||||
body: "{}",
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
}
|
||||
@@ -109,20 +121,21 @@ export async function logout(): Promise<void> {
|
||||
export async function registerPasskey(
|
||||
handle: string,
|
||||
displayName?: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AuthUser> {
|
||||
const optionsRequest: PasskeyRegistrationOptionsRequest = {
|
||||
handle,
|
||||
display_name: displayName ?? null,
|
||||
browser_origin: window.location.origin,
|
||||
};
|
||||
return await createPasskey("registration", optionsRequest);
|
||||
return await createPasskey("registration", optionsRequest, signal);
|
||||
}
|
||||
|
||||
export async function addPasskey(): Promise<AuthUser> {
|
||||
export async function addPasskey(signal?: AbortSignal): Promise<AuthUser> {
|
||||
const optionsRequest: PasskeyAdditionOptionsRequest = {
|
||||
browser_origin: window.location.origin,
|
||||
};
|
||||
return await createPasskey("addition", optionsRequest);
|
||||
return await createPasskey("addition", optionsRequest, signal);
|
||||
}
|
||||
|
||||
async function createPasskey(
|
||||
@@ -130,10 +143,12 @@ async function createPasskey(
|
||||
optionsRequest:
|
||||
| PasskeyRegistrationOptionsRequest
|
||||
| PasskeyAdditionOptionsRequest,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AuthUser> {
|
||||
const options = await requestJson(`/api/auth/passkeys/${purpose}/options`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(optionsRequest),
|
||||
signal,
|
||||
});
|
||||
const optionsRecord = typeof options === "object" && options !== null
|
||||
? options as Record<string, unknown>
|
||||
@@ -144,11 +159,16 @@ async function createPasskey(
|
||||
"Invalid auth payload: registration_options.challenge_id is required.",
|
||||
);
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const credential = await navigator.credentials.create({
|
||||
signal,
|
||||
publicKey: prepareRegistrationOptions(options),
|
||||
});
|
||||
if (!credential) throw new Error("Passkey registration was cancelled");
|
||||
if (!credential) {
|
||||
throw new DOMException("Passkey cancelled", "NotAllowedError");
|
||||
}
|
||||
|
||||
signal?.throwIfAborted();
|
||||
const completeRequest: PasskeyRegistrationCompleteRequest = {
|
||||
challenge_id: challengeId,
|
||||
credential: registrationCredentialToJson(credential),
|
||||
@@ -157,12 +177,16 @@ async function createPasskey(
|
||||
await requestJson(`/api/auth/passkeys/${purpose}/complete`, {
|
||||
method: "POST",
|
||||
body: JSON.stringify(completeRequest),
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
return result.user;
|
||||
}
|
||||
|
||||
export async function loginWithPasskey(handle: string): Promise<AuthUser> {
|
||||
export async function loginWithPasskey(
|
||||
handle: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<AuthUser> {
|
||||
const loginHandle = handle.trim();
|
||||
if (!loginHandle) {
|
||||
throw new Error("User handle is required for passkey login.");
|
||||
@@ -174,6 +198,7 @@ export async function loginWithPasskey(handle: string): Promise<AuthUser> {
|
||||
const options = await requestJson("/api/auth/passkeys/login/options", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(optionsRequest),
|
||||
signal,
|
||||
});
|
||||
const optionsRecord = typeof options === "object" && options !== null
|
||||
? options as Record<string, unknown>
|
||||
@@ -184,11 +209,16 @@ export async function loginWithPasskey(handle: string): Promise<AuthUser> {
|
||||
"Invalid auth payload: login_options.challenge_id is required.",
|
||||
);
|
||||
}
|
||||
signal?.throwIfAborted();
|
||||
const credential = await navigator.credentials.get({
|
||||
signal,
|
||||
publicKey: prepareLoginOptions(options),
|
||||
});
|
||||
if (!credential) throw new Error("Passkey login was cancelled");
|
||||
if (!credential) {
|
||||
throw new DOMException("Passkey cancelled", "NotAllowedError");
|
||||
}
|
||||
|
||||
signal?.throwIfAborted();
|
||||
const completeRequest: PasskeyLoginCompleteRequest = {
|
||||
challenge_id: challengeId,
|
||||
credential: authenticationCredentialToJson(credential),
|
||||
@@ -197,6 +227,7 @@ export async function loginWithPasskey(handle: string): Promise<AuthUser> {
|
||||
await requestJson("/api/auth/passkeys/login/complete", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(completeRequest),
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
return result.user;
|
||||
@@ -204,12 +235,14 @@ export async function loginWithPasskey(handle: string): Promise<AuthUser> {
|
||||
|
||||
export async function approveDeviceLogin(
|
||||
userCode: string,
|
||||
signal?: AbortSignal,
|
||||
): Promise<DeviceApprovalResponse> {
|
||||
const request: DeviceLoginApproveRequest = { user_code: userCode };
|
||||
return parseDeviceApprovalResponse(
|
||||
await requestJson("/api/auth/device-login/approve", {
|
||||
method: "POST",
|
||||
body: JSON.stringify(request),
|
||||
signal,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
.auth-page { display: grid; align-content: start; gap: var(--space-5); min-width: 0; }
|
||||
.auth-page h1 { margin: 0; font-size: var(--font-size-title); line-height: var(--line-height-title); }
|
||||
.auth-page h2 { margin: 0; font-size: var(--font-size-body); }
|
||||
.auth-page p { margin: 0; }
|
||||
.auth-page form, .auth-page section, .auth-page nav { display: grid; gap: var(--space-4); min-width: 0; }
|
||||
.auth-page label { display: grid; gap: var(--space-2); font-weight: 700; }
|
||||
.auth-page input { width: 100%; min-width: 0; padding: var(--space-2) var(--space-3); border: 1px solid var(--line-strong); border-radius: var(--radius-soft); background: var(--bg); color: var(--text-strong); font: inherit; }
|
||||
.auth-page button { justify-self: start; min-height: 2.25rem; padding: var(--space-2) var(--space-3); border: 1px solid var(--line-strong); border-radius: var(--radius-soft); background: transparent; color: var(--text-strong); font: inherit; cursor: pointer; }
|
||||
.auth-page button.primary { background: var(--text-strong); color: var(--bg); }
|
||||
.auth-page button:hover:not(:disabled) { background: var(--interactive-hover); color: var(--text-strong); }
|
||||
.auth-page button:disabled { opacity: .55; cursor: not-allowed; }
|
||||
.auth-page a { color: var(--text-strong); text-decoration: underline; text-underline-offset: .15em; overflow-wrap: anywhere; }
|
||||
.auth-page a:hover, .auth-page a:active { color: var(--text); }
|
||||
.auth-page :is(a, button, input):focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.auth-page .muted { color: var(--text-muted); }
|
||||
.auth-page .error { color: var(--danger); }
|
||||
.auth-page .actions { display: flex; flex-wrap: wrap; gap: var(--space-3); }
|
||||
.auth-page dl { display: grid; gap: var(--space-4); margin: 0; }
|
||||
.auth-page dl div { display: grid; gap: var(--space-1); min-width: 0; }
|
||||
.auth-page dt { color: var(--text-muted); }
|
||||
.auth-page a.entry-primary { justify-self: start; padding: var(--space-2) var(--space-4); border: 1px solid var(--line-strong); border-radius: var(--radius-soft); background: var(--text-strong); color: var(--bg); text-decoration: none; }
|
||||
.auth-page a.entry-primary:hover { background: var(--interactive-hover); color: var(--text-strong); }
|
||||
.auth-page a.entry-primary:active { background: var(--interactive-selected); color: var(--text-strong); }
|
||||
.auth-page dd { margin: 0; overflow-wrap: anywhere; }
|
||||
@@ -0,0 +1,104 @@
|
||||
import { AuthRequestError, loadWhoami } from "./api.ts";
|
||||
import { notifySessionChanged } from "./hosting.ts";
|
||||
import type { RequestActor } from "./model.ts";
|
||||
|
||||
export type BrowserSession =
|
||||
| { kind: "loading" }
|
||||
| { kind: "error" }
|
||||
| { kind: "ready"; actor: RequestActor | null };
|
||||
|
||||
export function authErrorMessage(error: unknown): string {
|
||||
if (error instanceof AuthRequestError) {
|
||||
if (error.status === 401) {
|
||||
return "Your session or authentication challenge expired. Log in again or restart the Passkey operation.";
|
||||
}
|
||||
if (error.status === 400 || error.status === 410) {
|
||||
return "The request or authentication challenge is invalid or expired. Restart the operation.";
|
||||
}
|
||||
return error.message;
|
||||
}
|
||||
if (error instanceof DOMException && error.name === "NotAllowedError") {
|
||||
return "Passkey operation was cancelled or timed out. You can try again.";
|
||||
}
|
||||
if (error instanceof DOMException && error.name === "AbortError") {
|
||||
return "Operation stopped. Completion is not confirmed; check your session before retrying.";
|
||||
}
|
||||
// Browser/transport例外の本文を表示せず、credentialや外部応答を診断へ複写しない。
|
||||
return "Authentication could not be confirmed. Check your session or try Passkey login before retrying registration.";
|
||||
}
|
||||
|
||||
export class BrowserAuth {
|
||||
session = $state<BrowserSession>({ kind: "loading" });
|
||||
busy = $state(false);
|
||||
error = $state("");
|
||||
status = $state("");
|
||||
private request = 0;
|
||||
private disposed = false;
|
||||
private controller: AbortController | null = null;
|
||||
|
||||
get actor(): RequestActor | null {
|
||||
return this.session.kind === "ready" ? this.session.actor : null;
|
||||
}
|
||||
|
||||
async refresh(): Promise<void> {
|
||||
if (this.busy || this.disposed) return;
|
||||
await this.readSession();
|
||||
}
|
||||
|
||||
private async readSession(): Promise<void> {
|
||||
const current = ++this.request;
|
||||
this.session = { kind: "loading" };
|
||||
try {
|
||||
const result = await loadWhoami();
|
||||
if (this.disposed || current !== this.request) return;
|
||||
this.session = { kind: "ready", actor: result.actor };
|
||||
} catch {
|
||||
if (this.disposed || current !== this.request) return;
|
||||
this.session = { kind: "error" };
|
||||
}
|
||||
}
|
||||
|
||||
async run(
|
||||
pending: string,
|
||||
success: string,
|
||||
operation: (signal: AbortSignal) => Promise<unknown>,
|
||||
): Promise<boolean> {
|
||||
if (this.busy || this.disposed || this.session.kind !== "ready") {
|
||||
return false;
|
||||
}
|
||||
this.busy = true;
|
||||
++this.request;
|
||||
const controller = new AbortController();
|
||||
this.controller = controller;
|
||||
this.error = "";
|
||||
this.status = pending;
|
||||
try {
|
||||
await operation(controller.signal);
|
||||
if (this.disposed) return false;
|
||||
notifySessionChanged();
|
||||
await this.readSession();
|
||||
if (this.disposed) return false;
|
||||
const confirmed = this.session.kind === "ready";
|
||||
this.status = confirmed ? success : "";
|
||||
return confirmed;
|
||||
} catch (error) {
|
||||
if (this.disposed) return false;
|
||||
this.error = authErrorMessage(error);
|
||||
this.status = "";
|
||||
// 失効/応答不明でも既存actorを操作権限として保持せず、正本を読み直す。
|
||||
await this.readSession();
|
||||
return false;
|
||||
} finally {
|
||||
if (!this.disposed) {
|
||||
this.busy = false;
|
||||
this.controller = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
dispose(): void {
|
||||
this.disposed = true;
|
||||
++this.request;
|
||||
this.controller?.abort();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
declare const Deno: { test(name: string, fn: () => Promise<void>): void };
|
||||
import {
|
||||
issueActivationCode,
|
||||
normalizeActivationHandle,
|
||||
} from "./hosting-accounts.ts";
|
||||
import { HostingRequestError } from "./hosting.ts";
|
||||
import { authUser } from "./api.test-fixtures.ts";
|
||||
function equal(a: unknown, b: unknown) {
|
||||
if (JSON.stringify(a) !== JSON.stringify(b)) throw new Error("Values differ");
|
||||
}
|
||||
async function boundary(fetcher: typeof fetch, run: () => Promise<void>) {
|
||||
const saved = globalThis.fetch;
|
||||
globalThis.fetch = fetcher;
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
globalThis.fetch = saved;
|
||||
}
|
||||
}
|
||||
const response = () => ({
|
||||
user: authUser,
|
||||
code: "synthetic-output-only",
|
||||
expires_at: new Date(Date.now() + 900000).toISOString(),
|
||||
});
|
||||
Deno.test("hosting issuance normalizes ASCII handle and selects create or explicit reissue using cookie only", async () => {
|
||||
for (const reissue of [false, true]) {
|
||||
let calls = 0;
|
||||
await boundary(async (input, requestInit) => {
|
||||
const init = requestInit as RequestInit;
|
||||
calls++;
|
||||
equal(
|
||||
input,
|
||||
reissue
|
||||
? "/api/hosting/accounts/activation-code"
|
||||
: "/api/hosting/accounts",
|
||||
);
|
||||
equal(JSON.parse(String(init?.body)), { handle: "alice" });
|
||||
equal(init?.method, "POST");
|
||||
equal(init?.credentials, "same-origin");
|
||||
equal(init?.redirect, "error");
|
||||
equal(init?.cache, "no-store");
|
||||
equal(init?.referrerPolicy, "no-referrer");
|
||||
equal(new Headers(init?.headers).has("authorization"), false);
|
||||
return Response.json(response());
|
||||
}, async () => {
|
||||
const issued = await issueActivationCode(
|
||||
" ALIce ",
|
||||
reissue,
|
||||
new AbortController().signal,
|
||||
);
|
||||
equal(issued.user, authUser);
|
||||
equal(calls, 1);
|
||||
});
|
||||
}
|
||||
});
|
||||
Deno.test("hosting issuance rejects malformed mismatched secret-bearing responses and transport diagnostics", async () => {
|
||||
for (
|
||||
const mode of ["throw", "malformed", "mismatch", "extra", "expiry"] as const
|
||||
) {
|
||||
await boundary(async () => {
|
||||
if (mode === "throw") throw new Error("synthetic-output-only");
|
||||
const value = response();
|
||||
return Response.json(
|
||||
mode === "malformed"
|
||||
? { code: value.code }
|
||||
: mode === "extra"
|
||||
? { ...value, extra: true }
|
||||
: mode === "expiry"
|
||||
? { ...value, expires_at: "never" }
|
||||
: { ...value, user: { ...authUser, handle: "other" } },
|
||||
);
|
||||
}, async () => {
|
||||
try {
|
||||
await issueActivationCode("alice", false, new AbortController().signal);
|
||||
} catch (error) {
|
||||
equal(
|
||||
(error as Error).message,
|
||||
"Code issuance could not be confirmed.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("Expected rejection");
|
||||
});
|
||||
}
|
||||
});
|
||||
Deno.test("hosting issuance exposes only HTTP status for denial and never retries", async () => {
|
||||
for (const status of [401, 403, 409, 503]) {
|
||||
let calls = 0;
|
||||
await boundary(async () => {
|
||||
calls++;
|
||||
return new Response("synthetic-output-only", { status });
|
||||
}, async () => {
|
||||
try {
|
||||
await issueActivationCode("alice", true, new AbortController().signal);
|
||||
} catch (error) {
|
||||
equal(error instanceof HostingRequestError, true);
|
||||
equal((error as HostingRequestError).status, status);
|
||||
equal(
|
||||
(error as Error).message.includes("synthetic-output-only"),
|
||||
false,
|
||||
);
|
||||
equal(calls, 1);
|
||||
return;
|
||||
}
|
||||
throw new Error("Expected rejection");
|
||||
});
|
||||
}
|
||||
});
|
||||
Deno.test("hosting ID normalization rejects Unicode and invalid length instead of changing account identity", async () => {
|
||||
equal(normalizeActivationHandle(" AbC-_09 "), "abc-_09");
|
||||
for (const value of ["", "a".repeat(65), "A", "a.b", "a b", "İ"]) {
|
||||
let rejected = false;
|
||||
try {
|
||||
normalizeActivationHandle(value);
|
||||
} catch {
|
||||
rejected = true;
|
||||
}
|
||||
equal(rejected, true);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,69 @@
|
||||
import type { ActivationCodeResponse } from "#lib/generated/auth-api.ts";
|
||||
import { readBoundedAuthResponseJson } from "./api.ts";
|
||||
import { HostingRequestError } from "./hosting.ts";
|
||||
import { parseAuthUserResponse } from "./model.ts";
|
||||
|
||||
// Serverのnormalize_handleと同じASCII正規化。Unicode case foldingはしない。
|
||||
export function normalizeActivationHandle(value: string): string {
|
||||
const handle = value.trim().replace(
|
||||
/[A-Z]/g,
|
||||
(letter) => letter.toLowerCase(),
|
||||
);
|
||||
if (!/^[a-z0-9_-]{1,64}$/.test(handle)) {
|
||||
throw new Error("Invalid account ID.");
|
||||
}
|
||||
return handle;
|
||||
}
|
||||
|
||||
// 管理者は新規IDを作成して、発行結果のcodeと/activateの場所を本人へ別々に渡す。
|
||||
// 再発行は仮accountにだけ行い、旧code/ceremonyは失効する。応答喪失時は自動再送せず、
|
||||
// 本人のPasskey login確認後に必要なら明示再発行する。codeは発行結果のmemoryだけで扱う。
|
||||
export async function issueActivationCode(
|
||||
handle: string,
|
||||
reissue: boolean,
|
||||
signal: AbortSignal,
|
||||
): Promise<ActivationCodeResponse> {
|
||||
try {
|
||||
handle = normalizeActivationHandle(handle);
|
||||
const response = await fetch(
|
||||
reissue
|
||||
? "/api/hosting/accounts/activation-code"
|
||||
: "/api/hosting/accounts",
|
||||
{
|
||||
method: "POST",
|
||||
credentials: "same-origin",
|
||||
mode: "same-origin",
|
||||
redirect: "error",
|
||||
cache: "no-store",
|
||||
referrerPolicy: "no-referrer",
|
||||
signal,
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ handle }),
|
||||
},
|
||||
);
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new HostingRequestError(response.status);
|
||||
}
|
||||
const value = await readBoundedAuthResponseJson(response);
|
||||
if (!value || typeof value !== "object" || Array.isArray(value)) {
|
||||
throw new Error();
|
||||
}
|
||||
const record = value as Record<string, unknown>;
|
||||
if (
|
||||
Object.keys(record).sort().join(",") !== "code,expires_at,user" ||
|
||||
typeof record.code !== "string" || !record.code ||
|
||||
record.code.length > 4096 ||
|
||||
typeof record.expires_at !== "string" ||
|
||||
!Number.isFinite(Date.parse(record.expires_at))
|
||||
) throw new Error();
|
||||
const user = parseAuthUserResponse({ user: record.user }).user;
|
||||
if (user.handle !== handle) throw new Error();
|
||||
signal.throwIfAborted();
|
||||
return { user, code: record.code, expires_at: record.expires_at };
|
||||
} catch (error) {
|
||||
if (error instanceof HostingRequestError) throw error;
|
||||
// HTTP本文、JSON/transport元例外には秘密が含まれ得る。
|
||||
throw new Error("Code issuance could not be confirmed.");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
declare const Deno: { test(name: string, fn: () => Promise<void>): void };
|
||||
|
||||
import { enrollHostingPasskey } from "./hosting-enrollment.ts";
|
||||
import {
|
||||
authUser,
|
||||
credentialFixture,
|
||||
registrationOptions,
|
||||
} from "./api.test-fixtures.ts";
|
||||
|
||||
function equal(actual: unknown, expected: unknown): void {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error("Values differ");
|
||||
}
|
||||
}
|
||||
async function rejects(run: Promise<unknown>): Promise<void> {
|
||||
try {
|
||||
await run;
|
||||
} catch (error) {
|
||||
equal(
|
||||
error instanceof Error && error.message,
|
||||
"Passkey enrollment could not be confirmed.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
throw new Error("Expected rejection");
|
||||
}
|
||||
|
||||
// 本番clientとWebAuthn変換を接続し、fetchとbrowser credential境界だけを代替する。
|
||||
// ServerのBearer認証・署名・cookie発行を保証する試験ではない。
|
||||
async function boundary(
|
||||
fetcher: typeof fetch,
|
||||
run: () => Promise<void>,
|
||||
create: (options: CredentialCreationOptions) => Promise<Credential | null> =
|
||||
async () => credentialFixture(),
|
||||
) {
|
||||
const descriptors = new Map(
|
||||
["fetch", "window", "navigator"].map((
|
||||
key,
|
||||
) => [key, Object.getOwnPropertyDescriptor(globalThis, key)]),
|
||||
);
|
||||
Object.defineProperties(globalThis, {
|
||||
fetch: { configurable: true, writable: true, value: fetcher },
|
||||
window: {
|
||||
configurable: true,
|
||||
value: {
|
||||
isSecureContext: true,
|
||||
location: { origin: "https://yoi.example" },
|
||||
},
|
||||
},
|
||||
navigator: { configurable: true, value: { credentials: { create } } },
|
||||
});
|
||||
try {
|
||||
await run();
|
||||
} finally {
|
||||
for (const [key, descriptor] of descriptors) {
|
||||
if (descriptor) Object.defineProperty(globalThis, key, descriptor);
|
||||
else Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Deno.test("host enrollment sends bearer only to same-origin addition endpoints, never registration", async () => {
|
||||
const requests: { path: string; init: RequestInit }[] = [];
|
||||
const controller = new AbortController();
|
||||
await boundary(async (input, init) => {
|
||||
requests.push({ path: String(input), init: init! });
|
||||
return Response.json(
|
||||
String(input).endsWith("/options")
|
||||
? registrationOptions
|
||||
: { user: authUser },
|
||||
);
|
||||
}, async () => {
|
||||
equal(
|
||||
await enrollHostingPasskey("host-test-token", controller.signal),
|
||||
authUser,
|
||||
);
|
||||
equal(requests.map((request) => request.path), [
|
||||
"/api/auth/passkeys/addition/options",
|
||||
"/api/auth/passkeys/addition/complete",
|
||||
]);
|
||||
for (const { init } of requests) {
|
||||
equal(init.method, "POST");
|
||||
equal(init.credentials, "omit");
|
||||
equal(init.mode, "same-origin");
|
||||
equal(init.redirect, "error");
|
||||
equal(init.cache, "no-store");
|
||||
equal(init.referrerPolicy, "no-referrer");
|
||||
equal(
|
||||
new Headers(init.headers).get("authorization"),
|
||||
"Bearer host-test-token",
|
||||
);
|
||||
equal(String(init.body).includes("host-test-token"), false);
|
||||
}
|
||||
equal(JSON.parse(String(requests[0].init.body)), {
|
||||
browser_origin: "https://yoi.example",
|
||||
});
|
||||
const complete = JSON.parse(String(requests[1].init.body));
|
||||
equal(Object.keys(complete).sort(), ["challenge_id", "credential"]);
|
||||
equal(complete.challenge_id, registrationOptions.challenge_id);
|
||||
equal(complete.credential.response.attestationObject, "AQID");
|
||||
}, async (options) => {
|
||||
equal([...new Uint8Array(options.publicKey!.challenge as ArrayBuffer)], [
|
||||
1,
|
||||
2,
|
||||
3,
|
||||
]);
|
||||
equal([...new Uint8Array(options.publicKey!.user.id as ArrayBuffer)], [
|
||||
4,
|
||||
5,
|
||||
6,
|
||||
]);
|
||||
equal(options.signal === controller.signal, true);
|
||||
return credentialFixture();
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("host enrollment refuses missing, oversized and header-injecting tokens before network", async () => {
|
||||
let calls = 0;
|
||||
await boundary(async () => {
|
||||
calls++;
|
||||
return Response.json({});
|
||||
}, async () => {
|
||||
for (const token of ["", " ", "x\r\ny", "x".repeat(4097)]) {
|
||||
await rejects(enrollHostingPasskey(token, new AbortController().signal));
|
||||
}
|
||||
equal(calls, 0);
|
||||
});
|
||||
});
|
||||
|
||||
Deno.test("host enrollment sanitizes rejection and thrown diagnostics without retry", async () => {
|
||||
for (const failure of [401, 403, 503, "throw"] as const) {
|
||||
let calls = 0;
|
||||
await boundary(async () => {
|
||||
calls++;
|
||||
if (failure === "throw") throw new Error("host-test-token in diagnostic");
|
||||
return new Response("host-test-token", { status: failure });
|
||||
}, async () => {
|
||||
await rejects(
|
||||
enrollHostingPasskey("host-test-token", new AbortController().signal),
|
||||
);
|
||||
equal(calls, 1);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("host enrollment validates options before opening credential prompt", async () => {
|
||||
for (
|
||||
const payload of [{}, { ...registrationOptions, challenge_id: "" }, {
|
||||
...registrationOptions,
|
||||
unexpected: true,
|
||||
}]
|
||||
) {
|
||||
let calls = 0;
|
||||
await boundary(async () => Response.json(payload), async () => {
|
||||
await rejects(
|
||||
enrollHostingPasskey("host-test-token", new AbortController().signal),
|
||||
);
|
||||
equal(calls, 0);
|
||||
}, async () => {
|
||||
calls++;
|
||||
return credentialFixture();
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("host enrollment cancellation and teardown prevent completion", async () => {
|
||||
for (const abort of [false, true]) {
|
||||
const controller = new AbortController();
|
||||
let requests = 0;
|
||||
await boundary(async () => {
|
||||
requests++;
|
||||
return Response.json(registrationOptions);
|
||||
}, async () => {
|
||||
await rejects(enrollHostingPasskey("host-test-token", controller.signal));
|
||||
equal(requests, 1);
|
||||
}, async () => {
|
||||
if (abort) {
|
||||
controller.abort();
|
||||
return credentialFixture();
|
||||
}
|
||||
return null;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("host enrollment cannot confirm a rejected, lost or malformed completion and never retries", async () => {
|
||||
for (const mode of ["rejected", "lost", "malformed", "oversized"] as const) {
|
||||
let calls = 0;
|
||||
await boundary(async (input) => {
|
||||
calls++;
|
||||
if (String(input).endsWith("/options")) {
|
||||
return Response.json(registrationOptions);
|
||||
}
|
||||
if (mode === "lost") throw new Error("host-test-token");
|
||||
if (mode === "rejected") return new Response(null, { status: 401 });
|
||||
if (mode === "oversized") {
|
||||
return new Response("{}", { headers: { "content-length": "262145" } });
|
||||
}
|
||||
return Response.json({ user: { handle: "host-test-token" } });
|
||||
}, async () => {
|
||||
await rejects(
|
||||
enrollHostingPasskey("host-test-token", new AbortController().signal),
|
||||
);
|
||||
equal(calls, 2);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("host enrollment sends no token on an insecure browser origin", async () => {
|
||||
let requests = 0;
|
||||
await boundary(async () => {
|
||||
requests++;
|
||||
return Response.json({});
|
||||
}, async () => {
|
||||
Object.defineProperty(window, "isSecureContext", { value: false });
|
||||
await rejects(
|
||||
enrollHostingPasskey("host-test-token", new AbortController().signal),
|
||||
);
|
||||
equal(requests, 0);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import type {
|
||||
PasskeyAdditionOptionsRequest,
|
||||
PasskeyRegistrationCompleteRequest,
|
||||
} from "#lib/generated/auth-api.ts";
|
||||
import { readBoundedAuthResponseJson } from "./api.ts";
|
||||
import {
|
||||
type AuthUser,
|
||||
parseAuthUserResponse,
|
||||
prepareRegistrationOptions,
|
||||
registrationCredentialToJson,
|
||||
} from "./model.ts";
|
||||
|
||||
// Host発行secretはこの操作のmemoryだけに置く。汎用API clientや永続sessionへ渡さない。
|
||||
// Cookieが既にあっても、本人の選択はServerがBearerから導出する。
|
||||
export async function enrollHostingPasskey(
|
||||
token: string,
|
||||
signal: AbortSignal,
|
||||
): Promise<AuthUser> {
|
||||
try {
|
||||
// WebAuthnが使えない非secure contextへsecretを送る前に拒否する。
|
||||
if (window.isSecureContext !== true) {
|
||||
throw new Error("Secure context required");
|
||||
}
|
||||
if (!/^[A-Za-z0-9._~-]{1,4096}$/.test(token)) {
|
||||
throw new Error("Invalid setup token");
|
||||
}
|
||||
const request = async (
|
||||
path:
|
||||
| "/api/auth/passkeys/addition/options"
|
||||
| "/api/auth/passkeys/addition/complete",
|
||||
body: PasskeyAdditionOptionsRequest | PasskeyRegistrationCompleteRequest,
|
||||
): Promise<unknown> => {
|
||||
signal.throwIfAborted();
|
||||
const response = await fetch(path, {
|
||||
method: "POST",
|
||||
// Bearerの失効を既存cookieで補わない。設定用tokenだけで本人を決め、
|
||||
// completionのcookieも受け取らず、次の明示Passkey loginでsessionを作る。
|
||||
credentials: "omit",
|
||||
mode: "same-origin",
|
||||
redirect: "error",
|
||||
cache: "no-store",
|
||||
referrerPolicy: "no-referrer",
|
||||
signal,
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new Error("Setup request rejected");
|
||||
}
|
||||
return await readBoundedAuthResponseJson(response);
|
||||
};
|
||||
const options = await request("/api/auth/passkeys/addition/options", {
|
||||
browser_origin: window.location.origin,
|
||||
});
|
||||
const publicKey = prepareRegistrationOptions(options);
|
||||
// prepareRegistrationOptionsでchallenge_idも含めて検証済み。
|
||||
const challengeId = (options as { challenge_id: string }).challenge_id;
|
||||
signal.throwIfAborted();
|
||||
const credential = await navigator.credentials.create({
|
||||
publicKey,
|
||||
signal,
|
||||
});
|
||||
if (!credential) throw new Error("Credential creation cancelled");
|
||||
signal.throwIfAborted();
|
||||
const result = await request("/api/auth/passkeys/addition/complete", {
|
||||
challenge_id: challengeId,
|
||||
credential: registrationCredentialToJson(credential),
|
||||
});
|
||||
return parseAuthUserResponse(result).user;
|
||||
} catch {
|
||||
// fetch/WebAuthn/responseの例外にはsecretを含む可能性があるため転載しない。
|
||||
throw new Error("Passkey enrollment could not be confirmed.");
|
||||
} finally {
|
||||
token = "";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
declare const Deno: {
|
||||
test(name: string, fn: () => void | Promise<void>): void;
|
||||
};
|
||||
|
||||
function assertEquals(actual: unknown, expected: unknown): void {
|
||||
if (JSON.stringify(actual) !== JSON.stringify(expected)) {
|
||||
throw new Error(
|
||||
`Expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function assertThrows(fn: () => unknown): void {
|
||||
try {
|
||||
fn();
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
throw new Error("Expected rejection");
|
||||
}
|
||||
|
||||
async function assertRejects(
|
||||
fn: () => Promise<unknown>,
|
||||
message: string,
|
||||
): Promise<Error> {
|
||||
try {
|
||||
await fn();
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes(message)) return error;
|
||||
throw new Error("Unexpected rejection");
|
||||
}
|
||||
throw new Error("Expected rejection");
|
||||
}
|
||||
import {
|
||||
HostingRequestError,
|
||||
loadHostingAccess,
|
||||
loadHostingSettings,
|
||||
loadRegistrationPolicy,
|
||||
parseHostingAccess,
|
||||
parseHostingSettings,
|
||||
saveHostingSettings,
|
||||
} from "./hosting.ts";
|
||||
|
||||
Deno.test("hosting projections accept only an exact boolean field", () => {
|
||||
for (const enabled of [true, false]) {
|
||||
assertEquals(parseHostingSettings({ self_registration_enabled: enabled }), {
|
||||
self_registration_enabled: enabled,
|
||||
});
|
||||
assertEquals(parseHostingAccess({ is_admin: enabled }), {
|
||||
is_admin: enabled,
|
||||
});
|
||||
}
|
||||
for (
|
||||
const value of [
|
||||
null,
|
||||
[],
|
||||
{},
|
||||
true,
|
||||
{ self_registration_enabled: "true" },
|
||||
{ self_registration_enabled: 1 },
|
||||
{ self_registration_enabled: true, secret: "unexpected" },
|
||||
{ is_admin: true },
|
||||
]
|
||||
) {
|
||||
assertThrows(() => parseHostingSettings(value));
|
||||
}
|
||||
for (
|
||||
const value of [null, [], {}, { is_admin: "true" }, {
|
||||
is_admin: true,
|
||||
user: "admin",
|
||||
}, { self_registration_enabled: true }]
|
||||
) {
|
||||
assertThrows(() => parseHostingAccess(value));
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("hosting adapters use distinct public/access/admin endpoints and same-origin credentials", async () => {
|
||||
const original = globalThis.fetch;
|
||||
const requests: { path: string; init?: RequestInit }[] = [];
|
||||
globalThis.fetch = ((input, init) => {
|
||||
const path = String(input);
|
||||
requests.push({ path, init });
|
||||
return Promise.resolve(
|
||||
Response.json(
|
||||
path.endsWith("/access")
|
||||
? { is_admin: false }
|
||||
: { self_registration_enabled: false },
|
||||
),
|
||||
);
|
||||
}) as typeof fetch;
|
||||
try {
|
||||
assertEquals(await loadRegistrationPolicy(), {
|
||||
self_registration_enabled: false,
|
||||
});
|
||||
assertEquals(await loadHostingAccess(), { is_admin: false });
|
||||
assertEquals(await loadHostingSettings(), {
|
||||
self_registration_enabled: false,
|
||||
});
|
||||
assertEquals(
|
||||
await saveHostingSettings({ self_registration_enabled: false }),
|
||||
{ self_registration_enabled: false },
|
||||
);
|
||||
assertEquals(requests.map((item) => item.path), [
|
||||
"/api/auth/registration-policy",
|
||||
"/api/hosting/access",
|
||||
"/api/hosting/settings",
|
||||
"/api/hosting/settings",
|
||||
]);
|
||||
assertEquals(
|
||||
requests.map((item) => item.init?.credentials),
|
||||
Array(4).fill("same-origin"),
|
||||
);
|
||||
assertEquals(
|
||||
requests.map((item) => item.init?.cache),
|
||||
Array(4).fill("no-store"),
|
||||
);
|
||||
assertEquals(requests[3].init?.method, "PUT");
|
||||
assertEquals(JSON.parse(String(requests[3].init?.body)), {
|
||||
self_registration_enabled: false,
|
||||
});
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("hosting failures expose status without response bodies and reject malformed success", async () => {
|
||||
const original = globalThis.fetch;
|
||||
try {
|
||||
for (const status of [401, 403, 503]) {
|
||||
globalThis.fetch = () =>
|
||||
Promise.resolve(new Response("private diagnostic", { status }));
|
||||
const error = await assertRejects(
|
||||
loadHostingSettings,
|
||||
`Hosting request failed (${status}).`,
|
||||
);
|
||||
if (!(error instanceof HostingRequestError)) {
|
||||
throw new Error("Expected HTTP status error");
|
||||
}
|
||||
assertEquals(error.status, status);
|
||||
}
|
||||
globalThis.fetch = () =>
|
||||
Promise.resolve(Response.json({ is_admin: "true" }));
|
||||
await assertRejects(loadHostingAccess, "Invalid hosting response");
|
||||
globalThis.fetch = () => Promise.resolve(new Response("invalid JSON"));
|
||||
await assertRejects(loadRegistrationPolicy, "not valid JSON");
|
||||
} finally {
|
||||
globalThis.fetch = original;
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,81 @@
|
||||
import type {
|
||||
HostingAccess,
|
||||
HostingSettings,
|
||||
} from "#lib/generated/auth-api.ts";
|
||||
import { readBoundedAuthResponseJson } from "./api.ts";
|
||||
|
||||
export class HostingRequestError extends Error {
|
||||
constructor(readonly status: number) {
|
||||
super(`Hosting request failed (${status}).`);
|
||||
}
|
||||
}
|
||||
|
||||
function booleanRecord(value: unknown, key: string): Record<string, boolean> {
|
||||
if (
|
||||
value === null || typeof value !== "object" || Array.isArray(value) ||
|
||||
Object.keys(value).length !== 1 ||
|
||||
!Object.hasOwn(value, key) ||
|
||||
typeof (value as Record<string, unknown>)[key] !== "boolean"
|
||||
) {
|
||||
throw new Error(
|
||||
`Invalid hosting response: expected only ${key} as a boolean.`,
|
||||
);
|
||||
}
|
||||
return value as Record<string, boolean>;
|
||||
}
|
||||
|
||||
export function parseHostingSettings(value: unknown): HostingSettings {
|
||||
return {
|
||||
self_registration_enabled: booleanRecord(value, "self_registration_enabled")
|
||||
.self_registration_enabled,
|
||||
};
|
||||
}
|
||||
|
||||
export function parseHostingAccess(value: unknown): HostingAccess {
|
||||
return { is_admin: booleanRecord(value, "is_admin").is_admin };
|
||||
}
|
||||
|
||||
async function requestJson(path: string, init?: RequestInit): Promise<unknown> {
|
||||
const response = await fetch(path, {
|
||||
credentials: "same-origin",
|
||||
cache: "no-store",
|
||||
...init,
|
||||
headers: { "content-type": "application/json", ...init?.headers },
|
||||
});
|
||||
if (!response.ok) {
|
||||
await response.body?.cancel();
|
||||
throw new HostingRequestError(response.status);
|
||||
}
|
||||
return await readBoundedAuthResponseJson(response);
|
||||
}
|
||||
|
||||
export async function loadRegistrationPolicy(): Promise<HostingSettings> {
|
||||
return parseHostingSettings(
|
||||
await requestJson("/api/auth/registration-policy"),
|
||||
);
|
||||
}
|
||||
|
||||
export async function loadHostingAccess(): Promise<HostingAccess> {
|
||||
return parseHostingAccess(await requestJson("/api/hosting/access"));
|
||||
}
|
||||
|
||||
export async function loadHostingSettings(): Promise<HostingSettings> {
|
||||
return parseHostingSettings(await requestJson("/api/hosting/settings"));
|
||||
}
|
||||
|
||||
export async function saveHostingSettings(
|
||||
settings: HostingSettings,
|
||||
): Promise<HostingSettings> {
|
||||
return parseHostingSettings(
|
||||
await requestJson("/api/hosting/settings", {
|
||||
method: "PUT",
|
||||
body: JSON.stringify(parseHostingSettings(settings)),
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Session変更は再取得の契機だけに使い、権限そのものはaccess endpointで確認する。
|
||||
export const SESSION_CHANGED_EVENT = "yoi:browser-session-changed";
|
||||
export function notifySessionChanged(): void {
|
||||
window.dispatchEvent(new Event(SESSION_CHANGED_EVENT));
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
declare const Deno: { test(name: string, run: () => void): void };
|
||||
function assertEquals(
|
||||
actual: string,
|
||||
expected: string,
|
||||
context?: string,
|
||||
): void {
|
||||
if (actual !== expected) {
|
||||
throw new Error(
|
||||
`${context ?? "return destination"}: expected ${expected}, got ${actual}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
import { authEntryUrl, loginUrl, safeReturnTo } from "./routes.ts";
|
||||
|
||||
Deno.test("return destination permits known app screens without forwarding arbitrary query or fragment", () => {
|
||||
for (
|
||||
const [input, expected] of [
|
||||
["/", "/dashboard"],
|
||||
["/dashboard?query=discard#fragment", "/dashboard"],
|
||||
["/account", "/account"],
|
||||
["/account?returnTo=//evil.invalid#secret", "/account"],
|
||||
[
|
||||
"/w/workspace-id/tickets/T-123?filter=all#entry",
|
||||
"/w/workspace-id/tickets/T-123",
|
||||
],
|
||||
[
|
||||
"/login/device?user_code=ABCD-EFGH",
|
||||
"/login/device?user_code=ABCD-EFGH",
|
||||
],
|
||||
[
|
||||
"/login/device?code=abcd-1234&returnTo=//evil.invalid",
|
||||
"/login/device?user_code=abcd-1234",
|
||||
],
|
||||
["/login/device?user_code=%252f%252fevil.invalid", "/login/device"],
|
||||
]
|
||||
) assertEquals(safeReturnTo(input), expected, input);
|
||||
});
|
||||
|
||||
Deno.test("return destination rejects external, ambiguous encoding and authentication loops", () => {
|
||||
for (
|
||||
const input of [
|
||||
null,
|
||||
"",
|
||||
"https://evil.invalid",
|
||||
"//evil.invalid",
|
||||
"///evil.invalid",
|
||||
"javascript:alert(1)",
|
||||
"account",
|
||||
"/\\evil.invalid",
|
||||
"/%2f%2fevil.invalid",
|
||||
"/%252f%252fevil.invalid",
|
||||
"/%61ccount",
|
||||
"/login",
|
||||
"/register",
|
||||
"/activate",
|
||||
"/login/",
|
||||
"/login?returnTo=/account",
|
||||
"/login/device/",
|
||||
"/login/../account",
|
||||
"/w/a/../../login",
|
||||
"/w/a//evil",
|
||||
"/w/a/./b",
|
||||
"/account\n",
|
||||
"/account\t",
|
||||
"/account\u0000",
|
||||
"/account%00",
|
||||
"/api/auth/logout",
|
||||
"/hosting/setup",
|
||||
"/not-an-app-route",
|
||||
"/" + "a".repeat(2049),
|
||||
]
|
||||
) assertEquals(safeReturnTo(input), "/dashboard", String(input));
|
||||
});
|
||||
|
||||
Deno.test("login links encode one validated destination and discard nested auth loops", () => {
|
||||
assertEquals(
|
||||
loginUrl("/login/device?user_code=ABCD-EFGH"),
|
||||
"/login?returnTo=%2Flogin%2Fdevice%3Fuser_code%3DABCD-EFGH",
|
||||
);
|
||||
assertEquals(
|
||||
authEntryUrl("/register", "/account"),
|
||||
"/register?returnTo=%2Faccount",
|
||||
);
|
||||
assertEquals(
|
||||
loginUrl("/login?returnTo=/login"),
|
||||
"/login?returnTo=%2Fdashboard",
|
||||
);
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
// 公開トップではなく明示的なWorkspace選択へ戻す。先頭Workspaceの推測はしない。
|
||||
// 戻り先は既知のアプリ画面だけ。認証経路への再帰やdecode段階の差による別originを作らない。
|
||||
export const AUTHENTICATED_HOME = "/dashboard";
|
||||
|
||||
export function safeReturnTo(value: string | null): string {
|
||||
if (
|
||||
!value || value.length > 2048 || !value.startsWith("/") ||
|
||||
/[\\\s\u0000-\u001f\u007f]/u.test(value) || value.startsWith("//")
|
||||
) {
|
||||
return AUTHENTICATED_HOME;
|
||||
}
|
||||
const rawPath = value.split(/[?#]/, 1)[0];
|
||||
if (
|
||||
rawPath.includes("%") || rawPath.includes("//") ||
|
||||
rawPath.split("/").some((part) => part === "." || part === "..")
|
||||
) {
|
||||
return AUTHENTICATED_HOME;
|
||||
}
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value, "https://app.invalid");
|
||||
} catch {
|
||||
return AUTHENTICATED_HOME;
|
||||
}
|
||||
if (url.origin !== "https://app.invalid") return AUTHENTICATED_HOME;
|
||||
if (url.pathname === "/login/device") {
|
||||
const code = url.searchParams.get("user_code") ??
|
||||
url.searchParams.get("code");
|
||||
if (code && /^[a-zA-Z0-9-]{1,64}$/.test(code)) {
|
||||
return `/login/device?user_code=${encodeURIComponent(code)}`;
|
||||
}
|
||||
return "/login/device";
|
||||
}
|
||||
if (
|
||||
url.pathname === AUTHENTICATED_HOME || url.pathname === "/account" ||
|
||||
/^\/w\/[a-zA-Z0-9_-]+(?:\/[a-zA-Z0-9_/-]*)?$/.test(url.pathname)
|
||||
) {
|
||||
return url.pathname;
|
||||
}
|
||||
return AUTHENTICATED_HOME;
|
||||
}
|
||||
|
||||
export function loginUrl(returnTo: string): string {
|
||||
return `/login?${new URLSearchParams({ returnTo: safeReturnTo(returnTo) })}`;
|
||||
}
|
||||
|
||||
export function authEntryUrl(
|
||||
route: "/login" | "/register",
|
||||
returnTo: string,
|
||||
): string {
|
||||
return `${route}?${new URLSearchParams({
|
||||
returnTo: safeReturnTo(returnTo),
|
||||
})}`;
|
||||
}
|
||||
|
||||
export function isAuthPage(path: string): boolean {
|
||||
return ["/login", "/register", "/account", "/activate", "/login/device"]
|
||||
.includes(path);
|
||||
}
|
||||
@@ -0,0 +1,467 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { BrowserAuth } from "./browser-session.svelte.ts";
|
||||
import { addPasskey, loginWithPasskey, logout } from "./api.ts";
|
||||
import { SESSION_CHANGED_EVENT } from "./hosting.ts";
|
||||
import {
|
||||
authUser,
|
||||
credentialFixture,
|
||||
loginOptions,
|
||||
registrationOptions,
|
||||
} from "./api.test-fixtures.ts";
|
||||
|
||||
const sessions: BrowserAuth[] = [];
|
||||
afterEach(() => {
|
||||
for (const auth of sessions.splice(0)) auth.dispose();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
function gate<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
function session() {
|
||||
const auth = new BrowserAuth();
|
||||
sessions.push(auth);
|
||||
return auth;
|
||||
}
|
||||
|
||||
function whoami(authenticated: boolean) {
|
||||
return Response.json({
|
||||
actor: authenticated
|
||||
? { ...authUser, auth_method: "browser_session" }
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
// BrowserAuth→本番HTTP/WebAuthn adapterの状態遷移を検証する。fetch/credentialだけを代替し、
|
||||
// 遅延応答は明示的なgateで順序を決める。実Backendの認証・永続化を保証する試験ではない。
|
||||
function boundary(authenticated = false) {
|
||||
const state = { authenticated, completeStatus: 200 };
|
||||
const create = vi.fn<CredentialsContainer["create"]>()
|
||||
.mockResolvedValue(credentialFixture());
|
||||
const get = vi.fn<CredentialsContainer["get"]>()
|
||||
.mockResolvedValue(credentialFixture(true));
|
||||
vi.stubGlobal("navigator", { credentials: { create, get } });
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (input) => {
|
||||
const path = String(input);
|
||||
if (path === "/api/auth/whoami") return whoami(state.authenticated);
|
||||
if (path === "/api/auth/logout") {
|
||||
state.authenticated = false;
|
||||
return Response.json({ status: "logged_out" });
|
||||
}
|
||||
if (path === "/api/auth/passkeys/login/options") {
|
||||
return Response.json(loginOptions);
|
||||
}
|
||||
if (path === "/api/auth/passkeys/addition/options") {
|
||||
return Response.json(registrationOptions);
|
||||
}
|
||||
if (
|
||||
path === "/api/auth/passkeys/login/complete" ||
|
||||
path === "/api/auth/passkeys/addition/complete"
|
||||
) {
|
||||
if (state.completeStatus !== 200) {
|
||||
if (state.completeStatus === 401) state.authenticated = false;
|
||||
return new Response(null, { status: state.completeStatus });
|
||||
}
|
||||
state.authenticated = true;
|
||||
return Response.json({ user: authUser });
|
||||
}
|
||||
throw new Error(`Unexpected API: ${path}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
return { fetcher, create, get, state };
|
||||
}
|
||||
|
||||
function paths(fetcher: ReturnType<typeof boundary>["fetcher"]) {
|
||||
return fetcher.mock.calls.map(([path]) => String(path));
|
||||
}
|
||||
|
||||
test("loading and failed whoami are not anonymous readiness and cannot start authentication", async () => {
|
||||
const { fetcher } = boundary();
|
||||
const response = gate<Response>();
|
||||
fetcher.mockReturnValueOnce(response.promise);
|
||||
const auth = session();
|
||||
const operation = vi.fn(async () => {});
|
||||
expect(auth.session.kind).toBe("loading");
|
||||
const refresh = auth.refresh();
|
||||
expect(auth.actor).toBeNull();
|
||||
expect(await auth.run("pending", "done", operation)).toBe(false);
|
||||
response.resolve(new Response(null, { status: 503 }));
|
||||
await refresh;
|
||||
expect(auth.session).toEqual({ kind: "error" });
|
||||
expect(await auth.run("pending", "done", operation)).toBe(false);
|
||||
expect(operation).not.toHaveBeenCalled();
|
||||
await auth.refresh();
|
||||
expect(auth.session).toEqual({ kind: "ready", actor: null });
|
||||
});
|
||||
|
||||
for (
|
||||
const [olderStatus, latestStatus, latestAuthenticated] of [
|
||||
[200, 200, false],
|
||||
[200, 503, false],
|
||||
[503, 200, true],
|
||||
] as const
|
||||
) {
|
||||
test(`latest whoami (${latestStatus}, actor=${latestAuthenticated}) wins over a delayed ${olderStatus} response`, async () => {
|
||||
const { fetcher } = boundary();
|
||||
const older = gate<Response>();
|
||||
const latest = gate<Response>();
|
||||
fetcher.mockReturnValueOnce(older.promise).mockReturnValueOnce(
|
||||
latest.promise,
|
||||
);
|
||||
const auth = session();
|
||||
const first = auth.refresh();
|
||||
const second = auth.refresh();
|
||||
latest.resolve(
|
||||
latestStatus === 200
|
||||
? whoami(latestAuthenticated)
|
||||
: new Response(null, { status: latestStatus }),
|
||||
);
|
||||
await second;
|
||||
expect(auth.session.kind).toBe(latestStatus === 200 ? "ready" : "error");
|
||||
expect(auth.actor?.handle ?? null).toBe(
|
||||
latestAuthenticated ? "alice" : null,
|
||||
);
|
||||
const accepted = auth.session;
|
||||
older.resolve(
|
||||
olderStatus === 200
|
||||
? whoami(true)
|
||||
: new Response(null, { status: olderStatus }),
|
||||
);
|
||||
await first;
|
||||
expect(auth.session).toBe(accepted);
|
||||
});
|
||||
}
|
||||
|
||||
test("a delayed pre-logout whoami cannot resurrect the previous actor", async () => {
|
||||
const { fetcher } = boundary(true);
|
||||
const auth = session();
|
||||
await auth.refresh();
|
||||
const oldResponse = gate<Response>();
|
||||
fetcher.mockReturnValueOnce(oldResponse.promise);
|
||||
const oldRefresh = auth.refresh();
|
||||
await auth.refresh();
|
||||
expect(auth.actor?.handle).toBe("alice");
|
||||
expect(await auth.run("Logging out", "Logged out", logout)).toBe(true);
|
||||
expect(auth.session).toEqual({ kind: "ready", actor: null });
|
||||
oldResponse.resolve(whoami(true));
|
||||
await oldRefresh;
|
||||
expect(auth.session).toEqual({ kind: "ready", actor: null });
|
||||
expect(auth.status).toBe("Logged out");
|
||||
});
|
||||
|
||||
test("busy login rejects duplicate operations and refresh until its post-completion whoami settles", async () => {
|
||||
const { fetcher } = boundary();
|
||||
const auth = session();
|
||||
await auth.refresh();
|
||||
const options = gate<Response>();
|
||||
const confirming = gate<Response>();
|
||||
const confirmingStarted = gate<void>();
|
||||
const request = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation((input, init) => {
|
||||
if (String(input) === "/api/auth/whoami") {
|
||||
confirmingStarted.resolve();
|
||||
return confirming.promise;
|
||||
}
|
||||
return request(input, init);
|
||||
});
|
||||
fetcher.mockReturnValueOnce(options.promise);
|
||||
const run = auth.run(
|
||||
"Waiting",
|
||||
"Logged in",
|
||||
(signal) => loginWithPasskey("alice", signal),
|
||||
);
|
||||
expect(auth.busy).toBe(true);
|
||||
expect(auth.status).toBe("Waiting");
|
||||
const duplicate = vi.fn(async () => {});
|
||||
expect(await auth.run("duplicate", "incorrect", duplicate)).toBe(false);
|
||||
await auth.refresh();
|
||||
expect(duplicate).not.toHaveBeenCalled();
|
||||
expect(paths(fetcher)).toEqual([
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/passkeys/login/options",
|
||||
]);
|
||||
options.resolve(Response.json(loginOptions));
|
||||
await confirmingStarted.promise;
|
||||
expect(auth.busy).toBe(true);
|
||||
expect(auth.session.kind).toBe("loading");
|
||||
expect(await auth.run("duplicate", "incorrect", duplicate)).toBe(false);
|
||||
await auth.refresh();
|
||||
expect(paths(fetcher).filter((path) => path === "/api/auth/whoami"))
|
||||
.toHaveLength(2);
|
||||
confirming.resolve(whoami(true));
|
||||
expect(await run).toBe(true);
|
||||
expect(auth.busy).toBe(false);
|
||||
expect(auth.actor?.handle).toBe("alice");
|
||||
expect(auth.status).toBe("Logged in");
|
||||
expect(paths(fetcher)).toEqual([
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/passkeys/login/options",
|
||||
"/api/auth/passkeys/login/complete",
|
||||
"/api/auth/whoami",
|
||||
]);
|
||||
});
|
||||
|
||||
for (const status of [400, 401, 410]) {
|
||||
test(`addition completion ${status} returns failure, clears success and rechecks the session`, async () => {
|
||||
const { fetcher, state } = boundary(true);
|
||||
const auth = session();
|
||||
await auth.refresh();
|
||||
state.completeStatus = status;
|
||||
const result = await auth.run("Waiting", "Added", addPasskey);
|
||||
expect(result).toBe(false);
|
||||
expect(auth.busy).toBe(false);
|
||||
expect(auth.status).toBe("");
|
||||
expect(auth.error).toMatch(
|
||||
status === 401
|
||||
? /Your session or authentication challenge expired/
|
||||
: /The request or authentication challenge is invalid or expired/,
|
||||
);
|
||||
expect(auth.session.kind).toBe("ready");
|
||||
expect(auth.actor?.handle ?? null).toBe(status === 401 ? null : "alice");
|
||||
expect(paths(fetcher)).toEqual([
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/passkeys/addition/options",
|
||||
"/api/auth/passkeys/addition/complete",
|
||||
"/api/auth/whoami",
|
||||
]);
|
||||
});
|
||||
}
|
||||
|
||||
for (const name of ["NotAllowedError", "AbortError", "Error"] as const) {
|
||||
test(`credential ${name} is sanitized, never completed, and leaves explicit retry possible`, async () => {
|
||||
const { fetcher, get } = boundary();
|
||||
const auth = session();
|
||||
await auth.refresh();
|
||||
get.mockRejectedValueOnce(
|
||||
name === "Error"
|
||||
? new Error("secret credential detail")
|
||||
: new DOMException("secret credential detail", name),
|
||||
);
|
||||
expect(
|
||||
await auth.run(
|
||||
"Waiting",
|
||||
"Logged in",
|
||||
(signal) => loginWithPasskey("alice", signal),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(auth.error).toMatch(
|
||||
name === "NotAllowedError"
|
||||
? /cancelled or timed out/
|
||||
: name === "AbortError"
|
||||
? /Completion is not confirmed/
|
||||
: /Authentication could not be confirmed/,
|
||||
);
|
||||
expect(auth.error).not.toContain("secret credential detail");
|
||||
expect(auth.status).toBe("");
|
||||
expect(auth.busy).toBe(false);
|
||||
expect(auth.session).toEqual({ kind: "ready", actor: null });
|
||||
expect(paths(fetcher)).toEqual([
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/passkeys/login/options",
|
||||
"/api/auth/whoami",
|
||||
]);
|
||||
expect(
|
||||
await auth.run(
|
||||
"Waiting",
|
||||
"Logged in",
|
||||
(signal) => loginWithPasskey("alice", signal),
|
||||
),
|
||||
).toBe(true);
|
||||
expect(auth.error).toBe("");
|
||||
expect(auth.actor?.handle).toBe("alice");
|
||||
});
|
||||
}
|
||||
|
||||
test("completion does not confirm authentication when the subsequent whoami fails", async () => {
|
||||
const { fetcher } = boundary();
|
||||
const auth = session();
|
||||
await auth.refresh();
|
||||
fetcher.mockImplementation(async (input) => {
|
||||
const path = String(input);
|
||||
if (path === "/api/auth/whoami") return new Response(null, { status: 503 });
|
||||
if (path === "/api/auth/passkeys/login/options") {
|
||||
return Response.json(loginOptions);
|
||||
}
|
||||
if (path === "/api/auth/passkeys/login/complete") {
|
||||
return Response.json({ user: authUser });
|
||||
}
|
||||
throw new Error(`Unexpected API: ${path}`);
|
||||
});
|
||||
expect(
|
||||
await auth.run(
|
||||
"Waiting",
|
||||
"Logged in",
|
||||
(signal) => loginWithPasskey("alice", signal),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(auth.session).toEqual({ kind: "error" });
|
||||
expect(auth.status).toBe("");
|
||||
expect(auth.actor).toBeNull();
|
||||
expect(auth.busy).toBe(false);
|
||||
});
|
||||
|
||||
for (const rejected of [false, true]) {
|
||||
test(`dispose ignores an outstanding whoami ${rejected ? "error" : "actor"} response and rejects future work`, async () => {
|
||||
const { fetcher } = boundary();
|
||||
const response = gate<Response>();
|
||||
fetcher.mockReturnValueOnce(response.promise);
|
||||
const auth = session();
|
||||
const refresh = auth.refresh();
|
||||
auth.dispose();
|
||||
response.resolve(
|
||||
rejected ? new Response(null, { status: 503 }) : whoami(true),
|
||||
);
|
||||
await refresh;
|
||||
expect(auth.session).toEqual({ kind: "loading" });
|
||||
expect(auth.actor).toBeNull();
|
||||
await auth.refresh();
|
||||
const operation = vi.fn(async () => {});
|
||||
expect(await auth.run("Waiting", "Logged in", operation)).toBe(false);
|
||||
expect(operation).not.toHaveBeenCalled();
|
||||
expect(fetcher).toHaveBeenCalledOnce();
|
||||
});
|
||||
}
|
||||
|
||||
test("dispose aborts WebAuthn and ignores a late credential without completion, session notification or refresh", async () => {
|
||||
const { fetcher, get } = boundary();
|
||||
const auth = session();
|
||||
await auth.refresh();
|
||||
const credential = gate<Credential | null>();
|
||||
const started = gate<AbortSignal>();
|
||||
get.mockImplementationOnce((options) => {
|
||||
started.resolve(options!.signal!);
|
||||
return credential.promise;
|
||||
});
|
||||
const dispatch = vi.spyOn(window, "dispatchEvent");
|
||||
const run = auth.run(
|
||||
"Waiting",
|
||||
"Logged in",
|
||||
(signal) => loginWithPasskey("alice", signal),
|
||||
);
|
||||
const signal = await started.promise;
|
||||
expect(signal.aborted).toBe(false);
|
||||
auth.dispose();
|
||||
expect(signal.aborted).toBe(true);
|
||||
credential.resolve(credentialFixture(true));
|
||||
expect(await run).toBe(false);
|
||||
expect(auth.session).toEqual({ kind: "ready", actor: null });
|
||||
expect(auth.status).toBe("Waiting");
|
||||
expect(auth.error).toBe("");
|
||||
expect(paths(fetcher)).toEqual([
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/passkeys/login/options",
|
||||
]);
|
||||
expect(
|
||||
dispatch.mock.calls.filter(([event]) =>
|
||||
event.type === SESSION_CHANGED_EVENT
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("dispose ignores a completion response even when the HTTP boundary does not honor abort", async () => {
|
||||
const { fetcher } = boundary();
|
||||
const auth = session();
|
||||
await auth.refresh();
|
||||
const response = gate<Response>();
|
||||
const started = gate<AbortSignal>();
|
||||
fetcher.mockImplementation(async (input, init) => {
|
||||
const path = String(input);
|
||||
if (path === "/api/auth/passkeys/login/options") {
|
||||
return Response.json(loginOptions);
|
||||
}
|
||||
if (path === "/api/auth/passkeys/login/complete") {
|
||||
started.resolve(init!.signal!);
|
||||
return await response.promise;
|
||||
}
|
||||
throw new Error(`Unexpected API: ${path}`);
|
||||
});
|
||||
const dispatch = vi.spyOn(window, "dispatchEvent");
|
||||
const run = auth.run(
|
||||
"Waiting",
|
||||
"Logged in",
|
||||
(signal) => loginWithPasskey("alice", signal),
|
||||
);
|
||||
const signal = await started.promise;
|
||||
auth.dispose();
|
||||
expect(signal.aborted).toBe(true);
|
||||
response.resolve(Response.json({ user: authUser }));
|
||||
expect(await run).toBe(false);
|
||||
expect(auth.actor).toBeNull();
|
||||
expect(auth.status).toBe("Waiting");
|
||||
expect(auth.error).toBe("");
|
||||
expect(paths(fetcher)).toEqual([
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/passkeys/login/options",
|
||||
"/api/auth/passkeys/login/complete",
|
||||
]);
|
||||
expect(
|
||||
dispatch.mock.calls.filter(([event]) =>
|
||||
event.type === SESSION_CHANGED_EVENT
|
||||
),
|
||||
).toEqual([]);
|
||||
});
|
||||
|
||||
test("dispose during the post-operation whoami prevents its late actor from becoming readiness", async () => {
|
||||
const { fetcher } = boundary();
|
||||
const auth = session();
|
||||
await auth.refresh();
|
||||
const response = gate<Response>();
|
||||
const started = gate<void>();
|
||||
fetcher.mockImplementation(async (input) => {
|
||||
const path = String(input);
|
||||
if (path === "/api/auth/passkeys/login/options") {
|
||||
return Response.json(loginOptions);
|
||||
}
|
||||
if (path === "/api/auth/passkeys/login/complete") {
|
||||
return Response.json({ user: authUser });
|
||||
}
|
||||
if (path === "/api/auth/whoami") {
|
||||
started.resolve();
|
||||
return await response.promise;
|
||||
}
|
||||
throw new Error(`Unexpected API: ${path}`);
|
||||
});
|
||||
const run = auth.run(
|
||||
"Waiting",
|
||||
"Logged in",
|
||||
(signal) => loginWithPasskey("alice", signal),
|
||||
);
|
||||
await started.promise;
|
||||
expect(auth.session.kind).toBe("loading");
|
||||
expect(auth.busy).toBe(true);
|
||||
auth.dispose();
|
||||
response.resolve(whoami(true));
|
||||
expect(await run).toBe(false);
|
||||
expect(auth.session.kind).toBe("loading");
|
||||
expect(auth.actor).toBeNull();
|
||||
});
|
||||
|
||||
for (const purpose of ["login", "addition"] as const) {
|
||||
test(`${purpose} null credential reports cancellation and never sends completion`, async () => {
|
||||
const { fetcher, get, create } = boundary(purpose === "addition");
|
||||
const auth = session();
|
||||
await auth.refresh();
|
||||
(purpose === "login" ? get : create).mockResolvedValueOnce(null);
|
||||
expect(
|
||||
await auth.run(
|
||||
"Waiting",
|
||||
"Confirmed",
|
||||
(signal) =>
|
||||
purpose === "login"
|
||||
? loginWithPasskey("alice", signal)
|
||||
: addPasskey(signal),
|
||||
),
|
||||
).toBe(false);
|
||||
expect(auth.error).toMatch(/cancelled or timed out/);
|
||||
expect(paths(fetcher).some((path) => path.endsWith("/complete"))).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
}
|
||||
@@ -72,8 +72,8 @@ Deno.test("workspace feature css is owned outside app css", async () => {
|
||||
workspaceLayout.includes("#lib/workspace/styles/workspace-pages.css") &&
|
||||
workspaceLayout.includes("#lib/workspace/styles/workers.css") &&
|
||||
settingsLayout.includes("#lib/workspace/styles/settings.css") &&
|
||||
accountPage.includes("#lib/workspace/styles/settings.css") &&
|
||||
deviceLoginPage.includes("#lib/workspace/styles/settings.css"),
|
||||
accountPage.includes("#lib/workspace/auth/auth-page.css") &&
|
||||
deviceLoginPage.includes("#lib/workspace/auth/auth-page.css"),
|
||||
"feature-owned CSS should be imported by the layouts/pages that need it",
|
||||
);
|
||||
});
|
||||
@@ -1277,13 +1277,7 @@ Deno.test("Worker Console route resolves logical Worker authority before Runtime
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test("Account UI owns browser passkey session state without workspace authorization", async () => {
|
||||
const accountPage = await Deno.readTextFile(
|
||||
new URL("./../../../routes/account/+page.svelte", import.meta.url),
|
||||
);
|
||||
const devicePage = await Deno.readTextFile(
|
||||
new URL("./../../../routes/login/device/+page.svelte", import.meta.url),
|
||||
);
|
||||
Deno.test("Root chrome owns global navigation and scoped override slots", async () => {
|
||||
const rootLayout = await Deno.readTextFile(
|
||||
new URL("./../../../routes/+layout.svelte", import.meta.url),
|
||||
);
|
||||
@@ -1321,21 +1315,6 @@ Deno.test("Account UI owns browser passkey session state without workspace autho
|
||||
new URL("../sidebar/WorkspaceSidebar.svelte", import.meta.url),
|
||||
);
|
||||
|
||||
assert(
|
||||
accountPage.includes("registerPasskey") &&
|
||||
accountPage.includes("loginWithPasskey") &&
|
||||
accountPage.includes("logout") &&
|
||||
accountPage.includes("loadWhoami") &&
|
||||
accountPage.includes("Current user"),
|
||||
"Account page should expose registration, login, logout, and current-session inspection",
|
||||
);
|
||||
assert(
|
||||
devicePage.includes("approveDeviceLogin") &&
|
||||
devicePage.includes("loginWithPasskey") &&
|
||||
devicePage.includes("user_code") &&
|
||||
devicePage.includes("Approve device login"),
|
||||
"Device login page should approve CLI login without DevTools console",
|
||||
);
|
||||
assert(
|
||||
rootLayout.includes("SIDEBAR_CONTEXT") &&
|
||||
rootLayout.includes("GlobalSidebar") &&
|
||||
|
||||
@@ -1,5 +1,42 @@
|
||||
<script lang="ts">
|
||||
import type { WorkspaceCatalogRecord } from "#lib/workspace/api/workspace-catalog.ts";
|
||||
import { onMount, untrack } from "svelte";
|
||||
import { loadWhoami } from "#lib/workspace/auth/api.ts";
|
||||
import { loadHostingAccess, SESSION_CHANGED_EVENT } from "#lib/workspace/auth/hosting.ts";
|
||||
|
||||
let mounted = $state(false);
|
||||
let isAdmin = $state(false);
|
||||
let accessRequest = 0;
|
||||
|
||||
async function refreshAccess() {
|
||||
const request = ++accessRequest;
|
||||
isAdmin = false;
|
||||
try {
|
||||
const whoami = await loadWhoami();
|
||||
if (request !== accessRequest || !whoami.actor) return;
|
||||
const access = await loadHostingAccess();
|
||||
if (request === accessRequest) isAdmin = access.is_admin;
|
||||
} catch {
|
||||
// 未確認の権限では管理導線を表示しない。直接screenでも独立して検証する。
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
const refresh = () => { void refreshAccess(); };
|
||||
window.addEventListener(SESSION_CHANGED_EVENT, refresh);
|
||||
window.addEventListener("focus", refresh);
|
||||
return () => {
|
||||
++accessRequest;
|
||||
window.removeEventListener(SESSION_CHANGED_EVENT, refresh);
|
||||
window.removeEventListener("focus", refresh);
|
||||
};
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
currentPath;
|
||||
if (mounted) untrack(() => { void refreshAccess(); });
|
||||
});
|
||||
|
||||
type Props = {
|
||||
currentPath: string;
|
||||
@@ -14,7 +51,8 @@
|
||||
}: Props = $props();
|
||||
|
||||
const globalItems = [
|
||||
{ href: "/#workspace-create-title", label: "Create Workspace" },
|
||||
{ href: "/dashboard", label: "Dashboard / Workspaces" },
|
||||
{ href: "/dashboard#workspace-create-title", label: "Create Workspace" },
|
||||
{ href: "/account", label: "Account" },
|
||||
{ href: "/login/device", label: "Device Login" },
|
||||
];
|
||||
@@ -37,6 +75,14 @@
|
||||
<span>{item.label}</span>
|
||||
</a>
|
||||
{/each}
|
||||
{#if isAdmin}
|
||||
<a
|
||||
class="sidebar-link"
|
||||
class:active={currentPath === "/hosting/settings"}
|
||||
href="/hosting/settings"
|
||||
aria-current={currentPath === "/hosting/settings" ? "page" : undefined}
|
||||
>Hosting settings</a>
|
||||
{/if}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -173,7 +173,7 @@
|
||||
<span>Workspaces</span>
|
||||
<a
|
||||
class="workspace-menu-add"
|
||||
href="/#workspace-create-title"
|
||||
href="/dashboard#workspace-create-title"
|
||||
role="menuitem"
|
||||
aria-label="Create Workspace"
|
||||
title="Create Workspace"
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
// @vitest-environment happy-dom
|
||||
import { cleanup, render, screen, waitFor } from "@testing-library/svelte";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import GlobalNavSections from "./GlobalNavSections.svelte";
|
||||
import { authUser } from "#lib/workspace/auth/api.test-fixtures.ts";
|
||||
import { notifySessionChanged } from "#lib/workspace/auth/hosting.ts";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
for (
|
||||
const persona of ["anonymous", "ordinary", "admin", "unavailable"] as const
|
||||
) {
|
||||
test(`global navigation exposes hosting settings only for confirmed admin, not ${persona}`, async () => {
|
||||
let actor = persona !== "anonymous";
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (input) => {
|
||||
if (String(input) === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
actor: actor ? { ...authUser, auth_method: "browser_session" } : null,
|
||||
});
|
||||
}
|
||||
if (String(input) === "/api/hosting/access") {
|
||||
if (persona === "unavailable") {
|
||||
return new Response(null, { status: 503 });
|
||||
}
|
||||
return Response.json({ is_admin: persona === "admin" });
|
||||
}
|
||||
throw new Error(`Unexpected API ${input}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
render(GlobalNavSections, {
|
||||
currentPath: "/hosting/settings",
|
||||
workspaces: [],
|
||||
});
|
||||
await waitFor(() => expect(fetcher).toHaveBeenCalled());
|
||||
if (persona === "admin") {
|
||||
const link = await screen.findByRole("link", {
|
||||
name: "Hosting settings",
|
||||
});
|
||||
expect(link.getAttribute("href")).toBe("/hosting/settings");
|
||||
expect(link.getAttribute("aria-current")).toBe("page");
|
||||
actor = false;
|
||||
notifySessionChanged();
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("link", { name: "Hosting settings" }))
|
||||
.toBeNull()
|
||||
);
|
||||
} else {
|
||||
if (persona !== "anonymous") {
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
fetcher.mock.calls.some(([path]) => path === "/api/hosting/access"),
|
||||
).toBe(true)
|
||||
);
|
||||
}
|
||||
expect(screen.queryByRole("link", { name: "Hosting settings" }))
|
||||
.toBeNull();
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -1,245 +0,0 @@
|
||||
@layer reset, tokens, base, layout, components;
|
||||
@layer components {
|
||||
.workspace-catalog-shell {
|
||||
width: min(1120px, calc(100% - 2rem));
|
||||
margin: 0 auto;
|
||||
padding: 3rem 0 5rem;
|
||||
display: grid;
|
||||
gap: 2.5rem;
|
||||
}
|
||||
|
||||
.workspace-catalog-heading,
|
||||
.workspace-card-heading,
|
||||
.workspace-create-row,
|
||||
.workspace-switcher-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.workspace-catalog-heading h1,
|
||||
.workspace-create-panel h2,
|
||||
.workspace-catalog-shell h2 {
|
||||
margin: 0.2rem 0 0.45rem;
|
||||
}
|
||||
|
||||
.workspace-catalog-heading p,
|
||||
.workspace-create-panel p,
|
||||
.workspace-empty-state p {
|
||||
color: var(--text-muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.workspace-catalog-eyebrow {
|
||||
color: var(--accent) !important;
|
||||
font-size: var(--font-size-compact);
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.12em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.workspace-catalog-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(250px, 100%), 1fr));
|
||||
gap: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.workspace-catalog-card,
|
||||
.workspace-create-panel,
|
||||
.workspace-empty-state {
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.75rem;
|
||||
background: var(--bg-raised);
|
||||
}
|
||||
|
||||
.workspace-catalog-card {
|
||||
color: inherit;
|
||||
padding: 1.1rem;
|
||||
display: grid;
|
||||
gap: 0.65rem;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.workspace-catalog-card:hover,
|
||||
.workspace-catalog-card:focus-visible {
|
||||
border-color: var(--accent);
|
||||
box-shadow: 0 0 0 1px var(--accent);
|
||||
}
|
||||
|
||||
.workspace-catalog-card code,
|
||||
.workspace-catalog-card small,
|
||||
.workspace-repository-summary small {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.workspace-card-heading > span {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-1);
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-compact);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.workspace-card-heading > span::before {
|
||||
width: 0.45rem;
|
||||
height: 0.45rem;
|
||||
border-radius: 50%;
|
||||
background: currentColor;
|
||||
content: "";
|
||||
}
|
||||
|
||||
.workspace-card-heading > .workspace-state-active {
|
||||
border-color: color-mix(in srgb, var(--success) 50%, var(--line));
|
||||
color: var(--success);
|
||||
}
|
||||
|
||||
.workspace-repository-summary {
|
||||
display: grid;
|
||||
gap: 0.2rem;
|
||||
}
|
||||
|
||||
.workspace-empty-state {
|
||||
padding: 1.5rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.workspace-create-panel {
|
||||
padding: 1.5rem;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(320px, 100%), 1fr));
|
||||
gap: 2rem;
|
||||
}
|
||||
|
||||
.workspace-create-panel form {
|
||||
display: grid;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.workspace-create-panel label {
|
||||
display: grid;
|
||||
gap: 0.35rem;
|
||||
color: var(--text-muted);
|
||||
font-size: var(--font-size-body);
|
||||
}
|
||||
|
||||
.workspace-create-panel input,
|
||||
.workspace-switcher select {
|
||||
min-width: 0;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.45rem;
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
padding: 0.65rem 0.75rem;
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.workspace-create-panel .workspace-repository-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-2);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.workspace-repository-fields {
|
||||
display: grid;
|
||||
gap: var(--space-4);
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workspace-create-panel input:not([type="checkbox"]) {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.workspace-create-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr));
|
||||
}
|
||||
|
||||
.workspace-create-panel label {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workspace-create-row > label {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.workspace-primary-action,
|
||||
.workspace-secondary-action {
|
||||
border-radius: 0.45rem;
|
||||
padding: 0.65rem 0.9rem;
|
||||
font: inherit;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.workspace-primary-action {
|
||||
border: 1px solid var(--accent);
|
||||
background: var(--accent);
|
||||
color: var(--bg);
|
||||
}
|
||||
|
||||
.workspace-secondary-action {
|
||||
border: 1px solid var(--line);
|
||||
background: var(--bg-raised);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.workspace-primary-action:disabled,
|
||||
.workspace-secondary-action:disabled {
|
||||
opacity: 0.55;
|
||||
cursor: wait;
|
||||
}
|
||||
|
||||
.workspace-catalog-alert {
|
||||
border-left: 3px solid var(--danger);
|
||||
background: color-mix(in srgb, var(--danger) 8%, transparent);
|
||||
padding: 0.75rem 0.9rem;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.workspace-switcher {
|
||||
display: grid;
|
||||
gap: 0.45rem;
|
||||
padding: 0 0.75rem 0.85rem;
|
||||
border-bottom: 1px solid var(--line);
|
||||
}
|
||||
|
||||
.workspace-switcher label {
|
||||
font-size: var(--font-size-compact);
|
||||
letter-spacing: 0.08em;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.workspace-switcher select {
|
||||
width: 100%;
|
||||
padding: 0.45rem 0.55rem;
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
|
||||
.workspace-switcher-actions {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
|
||||
.workspace-switcher-error {
|
||||
color: var(--danger);
|
||||
font-size: var(--font-size-compact);
|
||||
}
|
||||
|
||||
@media (max-width: 760px) {
|
||||
.workspace-create-panel {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.workspace-create-row,
|
||||
.workspace-catalog-heading {
|
||||
align-items: stretch;
|
||||
flex-direction: column;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,9 @@
|
||||
import { observeDeferred } from '#lib/workspace/navigation/observe-deferred.svelte.ts';
|
||||
|
||||
let { children, data }: LayoutProps = $props();
|
||||
import { isAuthPage } from '#lib/workspace/auth/routes.ts';
|
||||
// 公開入口・認証・Workspace選択はWorkspace catalogやSidebarに依存しない。
|
||||
const standalone = $derived(page.url.pathname === '/' || page.url.pathname === '/dashboard' || isAuthPage(page.url.pathname) || page.url.pathname.startsWith('/hosting/'));
|
||||
const catalog = observeDeferred(() => data.deferredCatalog, () => 'catalog');
|
||||
let sidebar = $state<SidebarSnippet | null>(null);
|
||||
const sidebarOverrides = createOverrideStack<SidebarSnippet>((activeSidebar) => {
|
||||
@@ -55,7 +58,7 @@
|
||||
const sidebarOpen = $derived((!mobileLayout.current && sidebarMode === 'pinned') || sidebarTransientOpen);
|
||||
|
||||
$effect(() => {
|
||||
const error = catalog.data?.workspaceCatalogError;
|
||||
const error = !standalone && catalog.data?.workspaceCatalogError;
|
||||
if (error) {
|
||||
pushWorkspaceAlert('error', error, {
|
||||
id: 'workspace-catalog-load',
|
||||
@@ -93,8 +96,12 @@
|
||||
});
|
||||
</script>
|
||||
|
||||
{#if standalone}
|
||||
<main class="standalone-main">
|
||||
{@render children()}
|
||||
</main>
|
||||
{:else}
|
||||
<WorkspaceAlerts />
|
||||
|
||||
<div class="app-shell" class:sidebar-open={sidebarOpen} class:sidebar-hover-mode={sidebarMode === 'hover'}>
|
||||
<SidebarFrame
|
||||
mode={sidebarMode}
|
||||
@@ -139,8 +146,17 @@
|
||||
{@render children()}
|
||||
</main>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.standalone-main {
|
||||
width: min(100%, 48rem);
|
||||
min-width: 0;
|
||||
min-height: 100dvh;
|
||||
margin-inline: auto;
|
||||
padding: var(--space-5) var(--space-4);
|
||||
}
|
||||
|
||||
.app-shell {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
|
||||
@@ -1,11 +1,20 @@
|
||||
import { isAuthPage } from "#lib/workspace/auth/routes.ts";
|
||||
import { listWorkspaces } from "#lib/workspace/api/workspace-catalog.ts";
|
||||
import { settle } from "#lib/workspace/navigation/deferred.ts";
|
||||
import type { Load } from "@sveltejs/kit";
|
||||
|
||||
export const ssr = false;
|
||||
|
||||
export const load = ({ fetch }: Parameters<Load>[0]) => ({
|
||||
export const load = ({ fetch, url }: Parameters<Load>[0]) => ({
|
||||
deferredCatalog: settle((async () => {
|
||||
// 公開入口・認証・hostingの判断はWorkspace catalogを必要としない。
|
||||
// dashboardはactor確認後に自身の一覧を取得する。
|
||||
if (
|
||||
url.pathname === "/" || url.pathname === "/dashboard" ||
|
||||
isAuthPage(url.pathname) || url.pathname.startsWith("/hosting/")
|
||||
) {
|
||||
return { accessibleWorkspaces: [], workspaceCatalogError: null };
|
||||
}
|
||||
try {
|
||||
return {
|
||||
accessibleWorkspaces: await listWorkspaces(fetch),
|
||||
@@ -14,7 +23,9 @@ export const load = ({ fetch }: Parameters<Load>[0]) => ({
|
||||
} catch (error) {
|
||||
return {
|
||||
accessibleWorkspaces: [],
|
||||
workspaceCatalogError: error instanceof Error ? error.message : "Unable to load Workspaces",
|
||||
workspaceCatalogError: error instanceof Error
|
||||
? error.message
|
||||
: "Unable to load Workspaces",
|
||||
};
|
||||
}
|
||||
})()),
|
||||
|
||||
@@ -1,10 +1,51 @@
|
||||
<script lang="ts">
|
||||
import DeferredPage from "#lib/workspace/navigation/DeferredPage.svelte";
|
||||
import PageContent from "./PageContent.svelte";
|
||||
import type { PageData } from "./$types";
|
||||
let { data }: { data: PageData } = $props();
|
||||
import { onMount } from 'svelte';
|
||||
import { BrowserAuth } from '#lib/workspace/auth/browser-session.svelte.ts';
|
||||
import { SESSION_CHANGED_EVENT } from '#lib/workspace/auth/hosting.ts';
|
||||
import { AUTHENTICATED_HOME } from '#lib/workspace/auth/routes.ts';
|
||||
import RegistrationLink from '#lib/workspace/auth/RegistrationLink.svelte';
|
||||
import AccountNavigation from '#lib/workspace/auth/AccountNavigation.svelte';
|
||||
import '#lib/workspace/auth/auth-page.css';
|
||||
|
||||
const auth = new BrowserAuth();
|
||||
onMount(() => {
|
||||
const refresh = () => { void auth.refresh(); };
|
||||
refresh();
|
||||
window.addEventListener('focus', refresh);
|
||||
window.addEventListener(SESSION_CHANGED_EVENT, refresh);
|
||||
return () => {
|
||||
auth.dispose();
|
||||
window.removeEventListener('focus', refresh);
|
||||
window.removeEventListener(SESSION_CHANGED_EVENT, refresh);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<DeferredPage {data} title="Workspaces">
|
||||
{#snippet children(resolved)}<PageContent data={resolved} />{/snippet}
|
||||
</DeferredPage>
|
||||
<svelte:head><title>Yoi · AIと進める開発</title></svelte:head>
|
||||
<div class="auth-page public-home" data-sveltekit-preload-data="off">
|
||||
<h1>Yoi</h1>
|
||||
<p>AIとともに、開発を前へ。</p>
|
||||
<p class="muted">Ticketで目的を共有し、AI Workerと実装を進める。Workspaceでコード・レビュー・作業の記録をまとめます。</p>
|
||||
<section aria-label="Get started">
|
||||
{#if auth.session.kind === 'loading'}
|
||||
<p class="muted" role="status">Loading session…</p>
|
||||
{:else if auth.session.kind === 'error'}
|
||||
<p class="error" role="alert">Your session could not be loaded. Login status is unknown.</p>
|
||||
<button type="button" onclick={() => auth.refresh()}>Retry session</button>
|
||||
<a href="/login">Open login</a>
|
||||
{:else if auth.actor}
|
||||
<p>Logged in as <strong>{auth.actor.display_name}</strong>.</p>
|
||||
<a class="entry-primary" href={AUTHENTICATED_HOME}>Open dashboard</a>
|
||||
{#key auth.actor.account_id}<AccountNavigation />{/key}
|
||||
{:else}
|
||||
<a class="entry-primary" href="/login">Log in</a>
|
||||
<RegistrationLink />
|
||||
<a href="/activate">Activate an account with a code</a>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.public-home { padding-block: var(--space-6); }
|
||||
.public-home > p { max-width: 40rem; }
|
||||
</style>
|
||||
|
||||
@@ -1,17 +0,0 @@
|
||||
import { deferPage } from "#lib/workspace/navigation/deferred.ts";
|
||||
import type { PageLoad } from "./$types";
|
||||
import { loadWorkspaceCatalog } from "#lib/workspace/api/workspace-catalog.ts";
|
||||
|
||||
export const load = deferPage(async ({ fetch }: Parameters<PageLoad>[0]) => {
|
||||
try {
|
||||
return {
|
||||
workspaces: await loadWorkspaceCatalog(fetch),
|
||||
catalogError: null,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
workspaces: [],
|
||||
catalogError: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
});
|
||||
@@ -1,194 +0,0 @@
|
||||
<script lang="ts">
|
||||
import { goto } from "$app/navigation";
|
||||
import {
|
||||
createOperationKey,
|
||||
createWorkspace,
|
||||
creationErrorMessage,
|
||||
loadWorkspaceCatalog,
|
||||
type CreateWorkspaceRequest,
|
||||
type WorkspaceCatalogItem,
|
||||
} from "#lib/workspace/api/workspace-catalog.ts";
|
||||
import "#lib/workspace/styles/workspace-catalog.css";
|
||||
|
||||
import type { PageData as RouteData } from "./$types";
|
||||
import type { ResolvedPageData } from "#lib/workspace/navigation/deferred.ts";
|
||||
let { data }: {data: ResolvedPageData<RouteData>} = $props();
|
||||
let workspaces = $state<WorkspaceCatalogItem[]>([]);
|
||||
let catalogError = $state<string | null>(null);
|
||||
let refreshing = $state(false);
|
||||
let creating = $state(false);
|
||||
let creationError = $state<string | null>(null);
|
||||
let displayName = $state("");
|
||||
let includeRepository = $state(false);
|
||||
let repositoryKey = $state("");
|
||||
let repositoryUri = $state("");
|
||||
let defaultRef = $state("");
|
||||
let lastSubmission = $state<{
|
||||
signature: string;
|
||||
request: CreateWorkspaceRequest;
|
||||
} | null>(null);
|
||||
|
||||
$effect(() => {
|
||||
workspaces = data.workspaces;
|
||||
catalogError = data.catalogError;
|
||||
});
|
||||
|
||||
async function refreshCatalog() {
|
||||
refreshing = true;
|
||||
catalogError = null;
|
||||
try {
|
||||
workspaces = await loadWorkspaceCatalog(fetch);
|
||||
} catch (error) {
|
||||
catalogError = error instanceof Error ? error.message : String(error);
|
||||
} finally {
|
||||
refreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function submitCreation(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
if (creating) return;
|
||||
const normalized = {
|
||||
display_name: displayName.trim(),
|
||||
repository: includeRepository ? {
|
||||
repository_key: repositoryKey.trim(),
|
||||
uri: repositoryUri.trim(),
|
||||
default_ref: defaultRef.trim() || null,
|
||||
} : null,
|
||||
};
|
||||
const signature = JSON.stringify(normalized);
|
||||
const request = lastSubmission?.signature === signature
|
||||
? lastSubmission.request
|
||||
: { operation_key: createOperationKey(), ...normalized };
|
||||
lastSubmission = { signature, request };
|
||||
creating = true;
|
||||
creationError = null;
|
||||
try {
|
||||
const response = await createWorkspace(fetch, request);
|
||||
await goto(`/w/${encodeURIComponent(response.workspace.workspace_id)}`);
|
||||
} catch (error) {
|
||||
creationError = creationErrorMessage(error);
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
|
||||
function formatUpdated(value: string): string {
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isNaN(timestamp)
|
||||
? value
|
||||
: new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(timestamp);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Workspaces · Yoi</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="workspace-catalog-shell">
|
||||
<section class="workspace-catalog-heading">
|
||||
<div>
|
||||
<p class="workspace-catalog-eyebrow">Backend</p>
|
||||
<h1>Workspaces</h1>
|
||||
<p>Select an accessible team space or create one on this Backend.</p>
|
||||
</div>
|
||||
<button class="workspace-secondary-action" onclick={refreshCatalog} disabled={refreshing}>
|
||||
{refreshing ? "Refreshing…" : "Refresh"}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{#if catalogError}
|
||||
<div class="workspace-catalog-alert" role="alert">
|
||||
Refresh failed. Existing results were kept. {catalogError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<section aria-labelledby="workspace-list-title">
|
||||
<h2 id="workspace-list-title">Available Workspaces</h2>
|
||||
{#if workspaces.length === 0}
|
||||
<div class="workspace-empty-state">
|
||||
<strong>No accessible Workspaces</strong>
|
||||
<p>Create the first Workspace if you have Backend permission.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="workspace-catalog-grid">
|
||||
{#each workspaces as workspace (workspace.workspace_id)}
|
||||
<a
|
||||
class="workspace-catalog-card"
|
||||
href={`/w/${encodeURIComponent(workspace.workspace_id)}`}
|
||||
>
|
||||
<span class="workspace-card-heading">
|
||||
<strong>{workspace.display_name}</strong>
|
||||
<span class:workspace-state-active={workspace.state === "active"}>
|
||||
{workspace.state}
|
||||
</span>
|
||||
</span>
|
||||
<code>{workspace.workspace_id}</code>
|
||||
{#if workspace.repositories[0]}
|
||||
<span class="workspace-repository-summary">
|
||||
{workspace.repositories[0].repository_key}
|
||||
<small>
|
||||
{workspace.repositories[0].default_selector ?? "repository default"} ·
|
||||
{workspace.repositories[0].kind}
|
||||
</small>
|
||||
</span>
|
||||
{:else if workspace.repository_error}
|
||||
<small>Repository summary unavailable</small>
|
||||
{:else}
|
||||
<small>No repositories</small>
|
||||
{/if}
|
||||
<small>Updated {formatUpdated(workspace.updated_at)}</small>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="workspace-create-panel" aria-labelledby="workspace-create-title">
|
||||
<div>
|
||||
<p class="workspace-catalog-eyebrow">New team space</p>
|
||||
<h2 id="workspace-create-title">Create Workspace</h2>
|
||||
<p>You can add repositories later in Workspace Settings.</p>
|
||||
</div>
|
||||
<form onsubmit={submitCreation}>
|
||||
<label>
|
||||
Workspace display name
|
||||
<input bind:value={displayName} required autocomplete="off" />
|
||||
</label>
|
||||
<label class="workspace-repository-toggle">
|
||||
<input type="checkbox" bind:checked={includeRepository} aria-controls={includeRepository ? "initial-repository-fields" : undefined} />
|
||||
Add an initial repository (optional)
|
||||
</label>
|
||||
{#if includeRepository}
|
||||
<div id="initial-repository-fields" class="workspace-repository-fields">
|
||||
<p>
|
||||
Repository sources are interpreted by Backend authority. Supported Git sources are absolute local paths, file://, ssh://, http(s)://, and user@host:path; Browser-local paths and embedded credentials are not authority. Plain HTTP is unencrypted, so prefer HTTPS or SSH.
|
||||
</p>
|
||||
<label>
|
||||
Initial repository absolute path or URI
|
||||
<input bind:value={repositoryUri} required autocomplete="off" />
|
||||
</label>
|
||||
<div class="workspace-create-row">
|
||||
<label>
|
||||
Repository key
|
||||
<input bind:value={repositoryKey} required pattern="[a-z0-9]|[a-z0-9](?:[a-z0-9]|-)*[a-z0-9]" maxlength="64" autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
Default ref
|
||||
<input bind:value={defaultRef} placeholder="repository default" autocomplete="off" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if creationError}
|
||||
<div class="workspace-catalog-alert" role="alert">{creationError}</div>
|
||||
{/if}
|
||||
<button class="workspace-primary-action" type="submit" disabled={creating}>
|
||||
{creating ? "Creating…" : creationError ? "Retry creation" : "Create Workspace"}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
@@ -1,171 +1,56 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import {
|
||||
addPasskey,
|
||||
loadWhoami,
|
||||
loginWithPasskey,
|
||||
logout,
|
||||
registerPasskey,
|
||||
} from '#lib/workspace/auth/api.ts';
|
||||
import '#lib/workspace/styles/settings.css';
|
||||
import type { RequestActor } from '#lib/workspace/auth/model.ts';
|
||||
import { goto } from '$app/navigation';
|
||||
import { addPasskey, logout } from '#lib/workspace/auth/api.ts';
|
||||
import { BrowserAuth } from '#lib/workspace/auth/browser-session.svelte.ts';
|
||||
import { loginUrl } from '#lib/workspace/auth/routes.ts';
|
||||
import '#lib/workspace/auth/auth-page.css';
|
||||
|
||||
let actor = $state<RequestActor | null>(null);
|
||||
let handle = $state('local');
|
||||
let displayName = $state('Local User');
|
||||
let loading = $state(true);
|
||||
let sessionReady = $state(false);
|
||||
let busy = $state(false);
|
||||
let status = $state('');
|
||||
let error = $state('');
|
||||
|
||||
async function refreshWhoami() {
|
||||
loading = true;
|
||||
sessionReady = false;
|
||||
error = '';
|
||||
try {
|
||||
const whoami = await loadWhoami();
|
||||
actor = whoami.actor;
|
||||
sessionReady = true;
|
||||
if (actor?.handle) {
|
||||
handle = actor.handle;
|
||||
displayName = actor.display_name;
|
||||
}
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
const auth = new BrowserAuth();
|
||||
async function refresh() {
|
||||
await auth.refresh();
|
||||
if (auth.session.kind === 'ready' && !auth.actor) {
|
||||
await goto(loginUrl('/account'), { replaceState: true });
|
||||
}
|
||||
}
|
||||
|
||||
async function register() {
|
||||
if (busy || !sessionReady) return;
|
||||
const adding = actor !== null;
|
||||
busy = true;
|
||||
status = adding ? 'Waiting for passkey addition…' : 'Waiting for new account registration…';
|
||||
error = '';
|
||||
try {
|
||||
if (adding) {
|
||||
await addPasskey();
|
||||
} else {
|
||||
await registerPasskey(handle.trim(), displayName.trim() || handle.trim());
|
||||
}
|
||||
status = adding ? 'Passkey added to your account.' : 'New account registered.';
|
||||
await refreshWhoami();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
status = '';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
async function add() {
|
||||
if (!auth.actor) return;
|
||||
await auth.run('Waiting for passkey addition…', 'Passkey added to your account.', addPasskey);
|
||||
}
|
||||
|
||||
async function login() {
|
||||
busy = true;
|
||||
status = 'Waiting for passkey login…';
|
||||
error = '';
|
||||
try {
|
||||
await loginWithPasskey(handle.trim());
|
||||
status = 'Logged in.';
|
||||
await refreshWhoami();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
status = '';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function signOut() {
|
||||
busy = true;
|
||||
error = '';
|
||||
try {
|
||||
await logout();
|
||||
actor = null;
|
||||
status = 'Logged out.';
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
if (!auth.actor) return;
|
||||
const confirmed = await auth.run('Logging out…', 'Logged out.', logout);
|
||||
if (confirmed && !auth.actor) await goto(loginUrl('/account'), { replaceState: true });
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void refreshWhoami();
|
||||
});
|
||||
onMount(() => { void refresh(); return () => auth.dispose(); });
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Account · Yoi Workspace</title>
|
||||
</svelte:head>
|
||||
|
||||
<main class="settings-page">
|
||||
<header class="settings-header">
|
||||
<p class="eyebrow">Account</p>
|
||||
<h1>Account</h1>
|
||||
<p>Register a passkey, sign in, sign out, and inspect the current browser session.</p>
|
||||
</header>
|
||||
|
||||
<section class="settings-panel account-panel">
|
||||
<div>
|
||||
<h2>Current user</h2>
|
||||
{#if loading}
|
||||
<p class="muted">Loading session…</p>
|
||||
{:else if actor}
|
||||
<dl class="account-details">
|
||||
<div>
|
||||
<dt>Handle</dt>
|
||||
<dd>{actor.handle}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Display name</dt>
|
||||
<dd>{actor.display_name}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>User ID</dt>
|
||||
<dd><code>{actor.user_id}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Account ID</dt>
|
||||
<dd><code>{actor.account_id}</code></dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt>Auth kind</dt>
|
||||
<dd>{actor.auth_method}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
<div class="settings-action-row">
|
||||
<button type="button" disabled={busy} onclick={signOut}>Log out</button>
|
||||
<button class="secondary-button" type="button" disabled={busy} onclick={refreshWhoami}>Refresh</button>
|
||||
</div>
|
||||
{:else}
|
||||
<p class="muted">No browser session is active.</p>
|
||||
{/if}
|
||||
<svelte:head><title>Account · Yoi</title></svelte:head>
|
||||
<div class="auth-page" data-sveltekit-preload-data="off">
|
||||
<h1>Account</h1>
|
||||
{#if auth.session.kind === 'loading'}
|
||||
<p class="muted" role="status">Loading session…</p>
|
||||
{:else if auth.session.kind === 'error'}
|
||||
<p role="alert" class="error">Your session could not be loaded. Login status is unknown.</p>
|
||||
<button type="button" disabled={auth.busy} onclick={refresh}>Retry session</button>
|
||||
{:else if auth.actor}
|
||||
<dl>
|
||||
<div><dt>Account ID (handle)</dt><dd>{auth.actor.handle}</dd></div>
|
||||
<div><dt>Display name</dt><dd>{auth.actor.display_name}</dd></div>
|
||||
</dl>
|
||||
<section aria-label="Your Passkeys">
|
||||
<h2>Passkeys</h2>
|
||||
<button type="button" disabled={auth.busy} onclick={add}>Add passkey to {auth.actor.handle}</button>
|
||||
</section>
|
||||
<div class="actions">
|
||||
<button type="button" disabled={auth.busy} onclick={signOut}>Log out</button>
|
||||
<button type="button" disabled={auth.busy} onclick={refresh}>Refresh session</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="settings-panel">
|
||||
<h2>Passkey</h2>
|
||||
<form class="settings-form" onsubmit={(event) => { event.preventDefault(); }}>
|
||||
<label>
|
||||
{actor ? 'Current user handle' : 'User handle (new registration or login)'}
|
||||
<input bind:value={handle} disabled={busy || loading || !sessionReady || actor !== null} autocomplete="username webauthn" placeholder="local" />
|
||||
</label>
|
||||
<label>
|
||||
Display name (new account only)
|
||||
<input bind:value={displayName} disabled={busy || loading || !sessionReady || actor !== null} autocomplete="name" placeholder="Local User" />
|
||||
</label>
|
||||
<div class="settings-action-row">
|
||||
<button type="button" disabled={busy || loading || !sessionReady || (!actor && !handle.trim())} onclick={register}>{actor ? `Add passkey to ${actor.handle}` : 'Register new account'}</button>
|
||||
<button type="button" disabled={busy || loading || !sessionReady || actor !== null || !handle.trim()} onclick={login}>Log in with passkey</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{#if status}
|
||||
<p class="status-message">{status}</p>
|
||||
{:else}
|
||||
<p>No browser session is active.</p>
|
||||
<a href={loginUrl('/account')}>Log in to your account</a>
|
||||
{/if}
|
||||
{#if error}
|
||||
<p class="error-message">{error}</p>
|
||||
{/if}
|
||||
</main>
|
||||
{#if auth.status}<p role="status">{auth.status}</p>{/if}
|
||||
{#if auth.error}<p role="alert" class="error">{auth.error}</p>{/if}
|
||||
<a href="/dashboard">Dashboard / Workspaces</a>
|
||||
</div>
|
||||
|
||||
@@ -7,76 +7,262 @@ import {
|
||||
waitFor,
|
||||
} from "@testing-library/svelte";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { tick } from "svelte";
|
||||
import { goto } from "$app/navigation";
|
||||
import Account from "./+page.svelte";
|
||||
import Login from "../login/+page.svelte";
|
||||
import Register from "../register/+page.svelte";
|
||||
import Device from "../login/device/+page.svelte";
|
||||
import {
|
||||
authUser,
|
||||
credentialFixture,
|
||||
loginOptions,
|
||||
registrationOptions,
|
||||
} from "#lib/workspace/auth/api.test-fixtures.ts";
|
||||
|
||||
const page = vi.hoisted(() => ({ url: new URL("https://example.test/login") }));
|
||||
vi.mock("$app/navigation", () => ({ goto: vi.fn(async () => {}) }));
|
||||
vi.mock("$app/state", () => ({ page }));
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
page.url = new URL("https://example.test/login");
|
||||
});
|
||||
|
||||
// UIから本番HTTP adapterまでを接続し、fetch/ブラウザーcredential境界だけを代替する。
|
||||
function boundary(authenticated: boolean, completeStatus = 200) {
|
||||
const create = vi.fn(async () => credentialFixture());
|
||||
vi.stubGlobal("navigator", { credentials: { create } });
|
||||
function gate<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
type Policy = boolean | "error" | "loading";
|
||||
// 各routeのUI→本番HTTP/WebAuthn変換を接続し、fetchとcredential境界だけを代替する。
|
||||
// Backendの署名検証・認可・永続化はこのsuiteの保証対象ではない。
|
||||
function boundary({
|
||||
authenticated = false,
|
||||
completeStatus = 200,
|
||||
policy = true as Policy,
|
||||
} = {}) {
|
||||
const state = { authenticated, completeStatus, policy, whoamiStatus: 200 };
|
||||
const policyResponse = gate<Response>();
|
||||
const create = vi.fn<CredentialsContainer["create"]>()
|
||||
.mockResolvedValue(credentialFixture());
|
||||
const get = vi.fn<CredentialsContainer["get"]>()
|
||||
.mockResolvedValue(credentialFixture(true));
|
||||
vi.stubGlobal("navigator", { credentials: { create, get } });
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (input) => {
|
||||
const path = String(input);
|
||||
if (path === "/api/auth/whoami") {
|
||||
if (state.whoamiStatus !== 200) {
|
||||
return new Response(null, { status: state.whoamiStatus });
|
||||
}
|
||||
return Response.json({
|
||||
actor: authenticated
|
||||
actor: state.authenticated
|
||||
? { ...authUser, auth_method: "browser_session" }
|
||||
: null,
|
||||
});
|
||||
}
|
||||
if (path.endsWith("/options")) return Response.json(registrationOptions);
|
||||
if (path.endsWith("/complete")) {
|
||||
if (completeStatus !== 200) {
|
||||
return new Response(null, { status: completeStatus });
|
||||
if (path === "/api/auth/registration-policy") {
|
||||
if (state.policy === "loading") return await policyResponse.promise;
|
||||
if (state.policy === "error") return new Response(null, { status: 503 });
|
||||
return Response.json({ self_registration_enabled: state.policy });
|
||||
}
|
||||
if (path === "/api/auth/passkeys/login/options") {
|
||||
return Response.json(loginOptions);
|
||||
}
|
||||
if (
|
||||
/^\/api\/auth\/passkeys\/(registration|addition)\/options$/.test(path)
|
||||
) {
|
||||
return Response.json(registrationOptions);
|
||||
}
|
||||
if (
|
||||
/^\/api\/auth\/passkeys\/(login|registration|addition)\/complete$/.test(
|
||||
path,
|
||||
)
|
||||
) {
|
||||
if (state.completeStatus !== 200) {
|
||||
if (state.completeStatus === 403 && path.includes("/registration/")) {
|
||||
state.policy = false;
|
||||
}
|
||||
if (state.completeStatus === 401) state.authenticated = false;
|
||||
return new Response(null, { status: state.completeStatus });
|
||||
}
|
||||
authenticated = true;
|
||||
state.authenticated = true;
|
||||
return Response.json({ user: authUser });
|
||||
}
|
||||
if (path === "/api/auth/logout") {
|
||||
state.authenticated = false;
|
||||
return Response.json({ status: "logged_out" });
|
||||
}
|
||||
if (path === "/api/auth/device-login/approve") {
|
||||
if (state.completeStatus !== 200) {
|
||||
if (state.completeStatus === 401) state.authenticated = false;
|
||||
return new Response(null, { status: state.completeStatus });
|
||||
}
|
||||
return Response.json({ status: "approved", user: authUser });
|
||||
}
|
||||
throw new Error(`Unexpected API: ${path}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
return { fetcher, create };
|
||||
return { fetcher, create, get, state, policyResponse };
|
||||
}
|
||||
|
||||
function posted(fetcher: ReturnType<typeof boundary>["fetcher"]) {
|
||||
return fetcher.mock.calls.filter(([, init]) => init?.method === "POST").map((
|
||||
[path, init],
|
||||
) => ({
|
||||
path: String(path),
|
||||
body: JSON.parse(String(init!.body)),
|
||||
credentials: init!.credentials,
|
||||
}));
|
||||
return fetcher.mock.calls.filter(([, init]) => init?.method === "POST").map(
|
||||
([path, init]) => ({
|
||||
path: String(path),
|
||||
body: JSON.parse(String(init!.body)),
|
||||
credentials: init!.credentials,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test("anonymous existing Account page registers a new account via registration endpoints", async () => {
|
||||
const { fetcher, create } = boundary(false);
|
||||
render(Account);
|
||||
const button = await screen.findByRole("button", {
|
||||
name: "Register new account",
|
||||
function apiPaths(fetcher: ReturnType<typeof boundary>["fetcher"]) {
|
||||
return fetcher.mock.calls.map(([path]) => String(path));
|
||||
}
|
||||
|
||||
async function enterIdentity(registering = false) {
|
||||
await fireEvent.input(await screen.findByLabelText("Account ID (handle)"), {
|
||||
target: { value: " alice " },
|
||||
});
|
||||
if (registering) {
|
||||
await fireEvent.input(screen.getByLabelText("Display name"), {
|
||||
target: { value: " Alice " },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function submit(name: string) {
|
||||
await fireEvent.submit(screen.getByRole("button", { name }).closest("form")!);
|
||||
}
|
||||
|
||||
test("login asks only for an account ID and links separate registration and code activation", async () => {
|
||||
const { fetcher } = boundary({ policy: true });
|
||||
page.url = new URL("https://example.test/login?returnTo=%2Faccount");
|
||||
render(Login);
|
||||
const handle = await screen.findByLabelText("Account ID (handle)");
|
||||
expect(screen.queryByLabelText("Display name")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Register new account" }))
|
||||
.toBeNull();
|
||||
expect(
|
||||
(await screen.findByRole("link", { name: "Create a new account" }))
|
||||
.getAttribute(
|
||||
"href",
|
||||
),
|
||||
)
|
||||
.toBe("/register?returnTo=%2Faccount");
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Activate an account with a code" })
|
||||
.getAttribute("href"),
|
||||
)
|
||||
.toBe("/activate");
|
||||
await fireEvent.input(handle, { target: { value: " " } });
|
||||
expect(
|
||||
(screen.getByRole("button", {
|
||||
name: "Log in with passkey",
|
||||
}) as HTMLButtonElement).disabled,
|
||||
)
|
||||
.toBe(true);
|
||||
await submit("Log in with passkey");
|
||||
expect(posted(fetcher)).toEqual([]);
|
||||
expect(apiPaths(fetcher)).toContain("/api/auth/registration-policy");
|
||||
});
|
||||
|
||||
for (
|
||||
const [returnTo, expected] of [
|
||||
[null, "/dashboard"],
|
||||
["/account", "/account"],
|
||||
["/w/project/tickets?ignored=1#fragment", "/w/project/tickets"],
|
||||
[
|
||||
"/login/device?code=abcd-EFGH&untrusted=1",
|
||||
"/login/device?user_code=abcd-EFGH",
|
||||
],
|
||||
["https://evil.test/", "/dashboard"],
|
||||
["//evil.test/account", "/dashboard"],
|
||||
["/\\evil.test/account", "/dashboard"],
|
||||
["/%2f%2fevil.test", "/dashboard"],
|
||||
["/login?returnTo=/login", "/dashboard"],
|
||||
["/register", "/dashboard"],
|
||||
["/activate", "/dashboard"],
|
||||
] as const
|
||||
) {
|
||||
test(`login confirms whoami before navigating safely for returnTo=${returnTo}`, async () => {
|
||||
const { fetcher, get, create } = boundary();
|
||||
page.url = new URL("https://example.test/login");
|
||||
if (returnTo !== null) page.url.searchParams.set("returnTo", returnTo);
|
||||
render(Login);
|
||||
await enterIdentity();
|
||||
await submit("Log in with passkey");
|
||||
await waitFor(() =>
|
||||
expect(goto).toHaveBeenCalledWith(expected, { replaceState: true })
|
||||
);
|
||||
const requests = posted(fetcher);
|
||||
expect(requests.map(({ path }) => path)).toEqual([
|
||||
"/api/auth/passkeys/login/options",
|
||||
"/api/auth/passkeys/login/complete",
|
||||
]);
|
||||
expect(requests[0].body).toEqual({
|
||||
handle: "alice",
|
||||
browser_origin: window.location.origin,
|
||||
});
|
||||
expect(requests[1].body.challenge_id).toBe(loginOptions.challenge_id);
|
||||
expect(get).toHaveBeenCalledOnce();
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
expect(apiPaths(fetcher).filter((path) => path === "/api/auth/whoami"))
|
||||
.toHaveLength(2);
|
||||
expect(apiPaths(fetcher)).not.toContain("/api/auth/device-login/approve");
|
||||
});
|
||||
}
|
||||
|
||||
test("successful login completion without confirmed whoami never navigates", async () => {
|
||||
const { state, get } = boundary();
|
||||
render(Login);
|
||||
await enterIdentity();
|
||||
get.mockImplementationOnce(async () => {
|
||||
state.whoamiStatus = 503;
|
||||
return credentialFixture(true);
|
||||
});
|
||||
await submit("Log in with passkey");
|
||||
await screen.findByText(/Your session could not be loaded/);
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("button", { name: "Log in with passkey" }))
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
test("register requires a separate ID and display name and uses only new-account endpoints", async () => {
|
||||
const { fetcher, create, get } = boundary();
|
||||
render(Register);
|
||||
await screen.findByLabelText("Display name");
|
||||
expect(
|
||||
(screen.getByRole("button", {
|
||||
name: "Register new account",
|
||||
}) as HTMLButtonElement).disabled,
|
||||
)
|
||||
.toBe(true);
|
||||
await fireEvent.input(screen.getByLabelText("Account ID (handle)"), {
|
||||
target: { value: " alice " },
|
||||
});
|
||||
expect(
|
||||
(screen.getByRole("button", {
|
||||
name: "Register new account",
|
||||
}) as HTMLButtonElement).disabled,
|
||||
)
|
||||
.toBe(true);
|
||||
expect(screen.queryByRole("button", { name: "Log in with passkey" }))
|
||||
.toBeNull();
|
||||
await fireEvent.input(screen.getByLabelText("Display name"), {
|
||||
target: { value: " Alice " },
|
||||
});
|
||||
await submit("Register new account");
|
||||
await waitFor(() =>
|
||||
expect((button as HTMLButtonElement).disabled).toBe(false)
|
||||
expect(goto).toHaveBeenCalledWith("/dashboard", { replaceState: true })
|
||||
);
|
||||
await fireEvent.input(
|
||||
screen.getByLabelText("User handle (new registration or login)"),
|
||||
{ target: { value: " alice " } },
|
||||
);
|
||||
await fireEvent.input(
|
||||
screen.getByLabelText("Display name (new account only)"),
|
||||
{ target: { value: "Alice" } },
|
||||
);
|
||||
await fireEvent.click(button);
|
||||
await screen.findByText("New account registered.");
|
||||
const requests = posted(fetcher);
|
||||
expect(requests.map((request) => request.path)).toEqual([
|
||||
expect(requests.map(({ path }) => path)).toEqual([
|
||||
"/api/auth/passkeys/registration/options",
|
||||
"/api/auth/passkeys/registration/complete",
|
||||
]);
|
||||
@@ -87,31 +273,100 @@ test("anonymous existing Account page registers a new account via registration e
|
||||
});
|
||||
expect(requests[1].body.challenge_id).toBe(registrationOptions.challenge_id);
|
||||
expect(create).toHaveBeenCalledOnce();
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("authenticated Account locks identity fields and adds only through self-only addition endpoints", async () => {
|
||||
const { fetcher, create } = boundary(true);
|
||||
render(Account);
|
||||
const button = await screen.findByRole("button", {
|
||||
name: "Add passkey to alice",
|
||||
for (const policy of [false, "error", "loading"] as const) {
|
||||
test(`register policy ${policy} prevents registration but preserves login and activation links`, async () => {
|
||||
const { fetcher, create, policyResponse } = boundary({ policy });
|
||||
render(Register);
|
||||
await screen.findByRole("heading", { name: "Create account" });
|
||||
if (policy === false) {
|
||||
await screen.findByText(/New account registration is disabled/);
|
||||
}
|
||||
if (policy === "error") {
|
||||
await screen.findByRole("button", {
|
||||
name: "Retry registration availability",
|
||||
});
|
||||
}
|
||||
if (policy === "loading") {
|
||||
await screen.findByText("Checking registration availability…");
|
||||
}
|
||||
await waitFor(() =>
|
||||
expect(apiPaths(fetcher)).toContain("/api/auth/whoami")
|
||||
);
|
||||
expect(screen.queryByLabelText("Account ID (handle)")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Register new account" }))
|
||||
.toBeNull();
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Log in with an existing account" })
|
||||
.getAttribute("href"),
|
||||
)
|
||||
.toBe("/login?returnTo=%2Fdashboard");
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Activate an account with a code" })
|
||||
.getAttribute("href"),
|
||||
)
|
||||
.toBe("/activate");
|
||||
expect(posted(fetcher)).toEqual([]);
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
if (policy === "loading") {
|
||||
policyResponse.resolve(
|
||||
Response.json({ self_registration_enabled: false }),
|
||||
);
|
||||
await screen.findByText(/New account registration is disabled/);
|
||||
}
|
||||
});
|
||||
expect(
|
||||
(screen.getByLabelText("Current user handle") as HTMLInputElement).disabled,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(screen.getByLabelText(
|
||||
"Display name (new account only)",
|
||||
) as HTMLInputElement).disabled,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(screen.getByRole("button", {
|
||||
name: "Log in with passkey",
|
||||
}) as HTMLButtonElement).disabled,
|
||||
).toBe(true);
|
||||
await fireEvent.click(button);
|
||||
}
|
||||
|
||||
test("policy disabled during WebAuthn rejects registration, reloads policy and never falls back to addition", async () => {
|
||||
const { fetcher, create, state } = boundary();
|
||||
const credential = gate<Credential | null>();
|
||||
create.mockReturnValueOnce(credential.promise);
|
||||
render(Register);
|
||||
await enterIdentity(true);
|
||||
await submit("Register new account");
|
||||
await waitFor(() => expect(create).toHaveBeenCalledOnce());
|
||||
state.completeStatus = 403;
|
||||
credential.resolve(credentialFixture());
|
||||
await screen.findByText("Auth request failed (403).");
|
||||
await screen.findByText(/New account registration is disabled/);
|
||||
expect(screen.queryByRole("button", { name: "Register new account" }))
|
||||
.toBeNull();
|
||||
expect(screen.queryByText("New account registered.")).toBeNull();
|
||||
expect(posted(fetcher).map(({ path }) => path)).toEqual([
|
||||
"/api/auth/passkeys/registration/options",
|
||||
"/api/auth/passkeys/registration/complete",
|
||||
]);
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("account displays the current identity without editable target fields and adds only to self", async () => {
|
||||
const { fetcher, create, get } = boundary({
|
||||
authenticated: true,
|
||||
policy: false,
|
||||
});
|
||||
render(Account);
|
||||
await screen.findByRole("button", { name: "Add passkey to alice" });
|
||||
expect(screen.getByText("alice")).toBeTruthy();
|
||||
expect(screen.getByText("Alice")).toBeTruthy();
|
||||
expect(screen.queryAllByRole("textbox")).toEqual([]);
|
||||
expect(screen.queryByRole("button", { name: "Log in with passkey" }))
|
||||
.toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Register new account" }))
|
||||
.toBeNull();
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Add passkey to alice" }),
|
||||
);
|
||||
await screen.findByText("Passkey added to your account.");
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(screen.getByRole("button", { name: "Log out" }) as HTMLButtonElement)
|
||||
.disabled,
|
||||
).toBe(false)
|
||||
);
|
||||
const requests = posted(fetcher);
|
||||
expect(requests.map((request) => request.path)).toEqual([
|
||||
expect(requests.map(({ path }) => path)).toEqual([
|
||||
"/api/auth/passkeys/addition/options",
|
||||
"/api/auth/passkeys/addition/complete",
|
||||
]);
|
||||
@@ -120,61 +375,304 @@ test("authenticated Account locks identity fields and adds only through self-onl
|
||||
"challenge_id",
|
||||
"credential",
|
||||
]);
|
||||
expect(requests.map((request) => request.credentials)).toEqual([
|
||||
expect(requests[1].body.challenge_id).toBe(registrationOptions.challenge_id);
|
||||
expect(requests.map(({ credentials }) => credentials)).toEqual([
|
||||
"same-origin",
|
||||
"same-origin",
|
||||
]);
|
||||
expect(apiPaths(fetcher)).not.toContain("/api/auth/registration-policy");
|
||||
expect(create).toHaveBeenCalledOnce();
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("addition completion rejection is visible and never retries as new registration", async () => {
|
||||
const { fetcher } = boundary(true, 401);
|
||||
test("anonymous account redirects to login with an account return path and has no credential inputs", async () => {
|
||||
const { fetcher } = boundary();
|
||||
render(Account);
|
||||
await waitFor(() =>
|
||||
expect(goto).toHaveBeenCalledWith("/login?returnTo=%2Faccount", {
|
||||
replaceState: true,
|
||||
})
|
||||
);
|
||||
expect(
|
||||
screen.getByRole("link", { name: "Log in to your account" }).getAttribute(
|
||||
"href",
|
||||
),
|
||||
)
|
||||
.toBe("/login?returnTo=%2Faccount");
|
||||
expect(screen.queryAllByRole("textbox")).toEqual([]);
|
||||
expect(posted(fetcher)).toEqual([]);
|
||||
});
|
||||
|
||||
for (
|
||||
const [route, Component] of [
|
||||
["login", Login],
|
||||
["register", Register],
|
||||
["account", Account],
|
||||
["device", Device],
|
||||
] as const
|
||||
) {
|
||||
test(`${route} keeps loading and unknown whoami separate from an anonymous session`, async () => {
|
||||
const { fetcher } = boundary();
|
||||
const response = gate<Response>();
|
||||
fetcher.mockReturnValueOnce(response.promise);
|
||||
if (Component === Login) render(Login);
|
||||
else if (Component === Register) render(Register);
|
||||
else if (Component === Account) render(Account);
|
||||
else render(Device);
|
||||
await screen.findByText("Loading session…");
|
||||
expect(
|
||||
screen.queryByRole("button", {
|
||||
name:
|
||||
/Log in with passkey|Register new account|Add passkey|Approve device login|Log out/,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
response.resolve(new Response(null, { status: 503 }));
|
||||
await screen.findByText(/Your session could not be loaded/);
|
||||
expect(screen.queryByText("No browser session is active.")).toBeNull();
|
||||
expect(
|
||||
screen.queryByRole("button", {
|
||||
name:
|
||||
/Log in with passkey|Register new account|Add passkey|Approve device login|Log out/,
|
||||
}),
|
||||
).toBeNull();
|
||||
expect(posted(fetcher)).toEqual([]);
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
|
||||
test("unknown account session shows retry instead of treating errors as anonymous or redirecting", async () => {
|
||||
const { state, fetcher } = boundary();
|
||||
state.whoamiStatus = 503;
|
||||
render(Account);
|
||||
await screen.findByText(/Your session could not be loaded/);
|
||||
expect(screen.queryByText("No browser session is active.")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: /Add passkey/ })).toBeNull();
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
state.whoamiStatus = 200;
|
||||
state.authenticated = true;
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Retry session" }));
|
||||
await screen.findByRole("button", { name: "Add passkey to alice" });
|
||||
expect(posted(fetcher)).toEqual([]);
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("logout posts to the session endpoint and confirms anonymous whoami before login navigation", async () => {
|
||||
const { fetcher } = boundary({ authenticated: true });
|
||||
render(Account);
|
||||
await fireEvent.click(await screen.findByRole("button", { name: "Log out" }));
|
||||
await waitFor(() =>
|
||||
expect(goto).toHaveBeenCalledWith("/login?returnTo=%2Faccount", {
|
||||
replaceState: true,
|
||||
})
|
||||
);
|
||||
expect(posted(fetcher)).toEqual([
|
||||
{ path: "/api/auth/logout", body: {}, credentials: "same-origin" },
|
||||
]);
|
||||
expect(apiPaths(fetcher)).toEqual([
|
||||
"/api/auth/whoami",
|
||||
"/api/auth/logout",
|
||||
"/api/auth/whoami",
|
||||
]);
|
||||
expect(screen.queryByRole("button", { name: /Add passkey/ })).toBeNull();
|
||||
});
|
||||
|
||||
test("expired addition revokes stale identity without silently retrying as registration", async () => {
|
||||
const { fetcher } = boundary({ authenticated: true, completeStatus: 401 });
|
||||
render(Account);
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Add passkey to alice" }),
|
||||
);
|
||||
await screen.findByText("Auth request failed (401).");
|
||||
expect(posted(fetcher).map((request) => request.path)).toEqual([
|
||||
await screen.findByText(/Your session or authentication challenge expired/);
|
||||
await screen.findByRole("link", { name: "Log in to your account" });
|
||||
expect(screen.queryByRole("button", { name: /Add passkey/ })).toBeNull();
|
||||
expect(screen.queryByText("Passkey added to your account.")).toBeNull();
|
||||
expect(posted(fetcher).map(({ path }) => path)).toEqual([
|
||||
"/api/auth/passkeys/addition/options",
|
||||
"/api/auth/passkeys/addition/complete",
|
||||
]);
|
||||
});
|
||||
|
||||
test("blank handle disables anonymous registration and login", async () => {
|
||||
boundary(false);
|
||||
render(Account);
|
||||
await screen.findByText("No browser session is active.");
|
||||
await fireEvent.input(
|
||||
screen.getByLabelText("User handle (new registration or login)"),
|
||||
{ target: { value: " " } },
|
||||
);
|
||||
test("pending login disables inputs and rejects duplicate form submits until the credential completes", async () => {
|
||||
const { fetcher, get } = boundary();
|
||||
const credential = gate<Credential | null>();
|
||||
get.mockReturnValueOnce(credential.promise);
|
||||
render(Login);
|
||||
await enterIdentity();
|
||||
await submit("Log in with passkey");
|
||||
await waitFor(() => expect(get).toHaveBeenCalledOnce());
|
||||
expect(
|
||||
(screen.getByRole("button", {
|
||||
name: "Register new account",
|
||||
}) as HTMLButtonElement).disabled,
|
||||
(screen.getByLabelText("Account ID (handle)") as HTMLInputElement).disabled,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(screen.getByRole("button", {
|
||||
name: "Log in with passkey",
|
||||
}) as HTMLButtonElement).disabled,
|
||||
).toBe(true);
|
||||
await submit("Log in with passkey");
|
||||
expect(posted(fetcher).map(({ path }) => path)).toEqual([
|
||||
"/api/auth/passkeys/login/options",
|
||||
]);
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
credential.resolve(credentialFixture(true));
|
||||
await waitFor(() => expect(goto).toHaveBeenCalledOnce());
|
||||
expect(get).toHaveBeenCalledOnce();
|
||||
expect(posted(fetcher)).toHaveLength(2);
|
||||
});
|
||||
|
||||
test("unknown session never enables registration or addition", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(null, { status: 503 })),
|
||||
test("WebAuthn cancellation is visible, sends no completion, and allows an explicit retry", async () => {
|
||||
const { fetcher, get } = boundary();
|
||||
get.mockRejectedValueOnce(
|
||||
new DOMException("sensitive browser detail", "NotAllowedError"),
|
||||
);
|
||||
render(Account);
|
||||
await screen.findByText("Auth request failed (503).");
|
||||
expect(
|
||||
(screen.getByRole("button", {
|
||||
name: "Register new account",
|
||||
}) as HTMLButtonElement).disabled,
|
||||
).toBe(true);
|
||||
expect(
|
||||
(screen.getByLabelText(
|
||||
"User handle (new registration or login)",
|
||||
) as HTMLInputElement).disabled,
|
||||
).toBe(true);
|
||||
render(Login);
|
||||
await enterIdentity();
|
||||
await submit("Log in with passkey");
|
||||
await screen.findByText(
|
||||
"Passkey operation was cancelled or timed out. You can try again.",
|
||||
);
|
||||
await waitFor(() =>
|
||||
expect(
|
||||
(screen.getByRole("button", {
|
||||
name: "Log in with passkey",
|
||||
}) as HTMLButtonElement).disabled,
|
||||
).toBe(false)
|
||||
);
|
||||
expect(screen.queryByText(/sensitive browser detail/)).toBeNull();
|
||||
expect(posted(fetcher).map(({ path }) => path)).toEqual([
|
||||
"/api/auth/passkeys/login/options",
|
||||
]);
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
await submit("Log in with passkey");
|
||||
await waitFor(() => expect(goto).toHaveBeenCalledOnce());
|
||||
expect(get).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
test("unmount aborts a pending WebAuthn login and ignores a credential that arrives afterwards", async () => {
|
||||
const { fetcher, get } = boundary();
|
||||
const credential = gate<Credential | null>();
|
||||
get.mockReturnValueOnce(credential.promise);
|
||||
const view = render(Login);
|
||||
await enterIdentity();
|
||||
await submit("Log in with passkey");
|
||||
await waitFor(() => expect(get).toHaveBeenCalledOnce());
|
||||
const signal = get.mock.calls[0][0]?.signal;
|
||||
expect(signal?.aborted).toBe(false);
|
||||
view.unmount();
|
||||
expect(signal?.aborted).toBe(true);
|
||||
credential.resolve(credentialFixture(true));
|
||||
await credential.promise;
|
||||
await tick();
|
||||
expect(posted(fetcher).map(({ path }) => path)).toEqual([
|
||||
"/api/auth/passkeys/login/options",
|
||||
]);
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("device login round trip preserves the code and still requires an explicit approval", async () => {
|
||||
const { fetcher } = boundary();
|
||||
page.url = new URL("https://example.test/login/device?user_code=abcd-efgh");
|
||||
const device = render(Device);
|
||||
const login = await screen.findByRole("link", { name: "Log in to continue" });
|
||||
expect(screen.queryByRole("button", { name: "Approve device login" }))
|
||||
.toBeNull();
|
||||
expect(posted(fetcher)).toEqual([]);
|
||||
const destination = new URL(login.getAttribute("href")!, page.url);
|
||||
expect(destination.pathname).toBe("/login");
|
||||
expect(destination.searchParams.get("returnTo")).toBe(
|
||||
"/login/device?user_code=abcd-efgh",
|
||||
);
|
||||
device.unmount();
|
||||
page.url = destination;
|
||||
const signIn = render(Login);
|
||||
await enterIdentity();
|
||||
await submit("Log in with passkey");
|
||||
await waitFor(() =>
|
||||
expect(goto).toHaveBeenCalledWith("/login/device?user_code=abcd-efgh", {
|
||||
replaceState: true,
|
||||
})
|
||||
);
|
||||
expect(apiPaths(fetcher)).not.toContain("/api/auth/device-login/approve");
|
||||
signIn.unmount();
|
||||
page.url = new URL("https://example.test/login/device?user_code=abcd-efgh");
|
||||
render(Device);
|
||||
const approve = await screen.findByRole("button", {
|
||||
name: "Approve device login",
|
||||
});
|
||||
expect((screen.getByLabelText("User code") as HTMLInputElement).value).toBe(
|
||||
"abcd-efgh",
|
||||
);
|
||||
expect(apiPaths(fetcher)).not.toContain("/api/auth/device-login/approve");
|
||||
await fireEvent.submit(approve.closest("form")!);
|
||||
await screen.findByText("Device login approved. You can return to the CLI.");
|
||||
await waitFor(() =>
|
||||
expect(screen.queryByRole("button", { name: "Approve device login" }))
|
||||
.toBeNull()
|
||||
);
|
||||
expect(
|
||||
posted(fetcher).filter(({ path }) =>
|
||||
path === "/api/auth/device-login/approve"
|
||||
),
|
||||
)
|
||||
.toEqual([{
|
||||
path: "/api/auth/device-login/approve",
|
||||
body: { user_code: "ABCD-EFGH" },
|
||||
credentials: "same-origin",
|
||||
}]);
|
||||
await fireEvent.submit(screen.getByLabelText("User code").closest("form")!);
|
||||
expect(
|
||||
posted(fetcher).filter(({ path }) =>
|
||||
path === "/api/auth/device-login/approve"
|
||||
),
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
test("device approval locks its code and rejects a second submit while the server response is pending", async () => {
|
||||
const { fetcher } = boundary({ authenticated: true });
|
||||
page.url = new URL("https://example.test/login/device?user_code=abcd-efgh");
|
||||
render(Device);
|
||||
const approve = await screen.findByRole("button", {
|
||||
name: "Approve device login",
|
||||
});
|
||||
const response = gate<Response>();
|
||||
fetcher.mockReturnValueOnce(response.promise);
|
||||
const form = approve.closest("form")!;
|
||||
await fireEvent.submit(form);
|
||||
await screen.findByText("Approving device login…");
|
||||
expect((approve as HTMLButtonElement).disabled).toBe(true);
|
||||
expect((screen.getByLabelText("User code") as HTMLInputElement).disabled)
|
||||
.toBe(true);
|
||||
await fireEvent.submit(form);
|
||||
expect(posted(fetcher).map(({ path }) => path)).toEqual([
|
||||
"/api/auth/device-login/approve",
|
||||
]);
|
||||
response.resolve(Response.json({ status: "approved", user: authUser }));
|
||||
await screen.findByText("Device login approved. You can return to the CLI.");
|
||||
expect(posted(fetcher)).toHaveLength(1);
|
||||
});
|
||||
|
||||
for (const status of [401, 410]) {
|
||||
test(`device approval ${status} is visible and never reports approval`, async () => {
|
||||
const { fetcher } = boundary({
|
||||
authenticated: true,
|
||||
completeStatus: status,
|
||||
});
|
||||
page.url = new URL("https://example.test/login/device?code=abcd-efgh");
|
||||
render(Device);
|
||||
await fireEvent.submit(
|
||||
(await screen.findByRole("button", { name: "Approve device login" }))
|
||||
.closest("form")!,
|
||||
);
|
||||
await screen.findByText(
|
||||
status === 401
|
||||
? /Your session or authentication challenge expired/
|
||||
: /The request or authentication challenge is invalid or expired/,
|
||||
);
|
||||
expect(
|
||||
screen.queryByText("Device login approved. You can return to the CLI."),
|
||||
).toBeNull();
|
||||
expect(posted(fetcher)).toHaveLength(1);
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy, onMount } from 'svelte';
|
||||
import { activateWithPasskey } from '#lib/workspace/auth/activation.ts';
|
||||
import { normalizeActivationHandle } from '#lib/workspace/auth/hosting-accounts.ts';
|
||||
import { notifySessionChanged } from '#lib/workspace/auth/hosting.ts';
|
||||
import type { AuthUser } from '#lib/workspace/auth/model.ts';
|
||||
|
||||
let handle = $state('');
|
||||
let code = $state('');
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
let user = $state<AuthUser | null>(null);
|
||||
let controller: AbortController | null = null;
|
||||
let generation = 0;
|
||||
// 本人はID+codeから開始し、WebAuthnとcomplete応答が揃うまで通常利用へ進ませない。
|
||||
// cancel/reloadはsecretを破棄して再入力。通信断ではactiveを推定しない。
|
||||
async function activate() {
|
||||
if (busy || user || !code) return;
|
||||
try { handle = normalizeActivationHandle(handle); }
|
||||
catch { error = 'Check the account ID supplied by your administrator.'; return; }
|
||||
const current = ++generation;
|
||||
controller = new AbortController(); busy = true; error = '';
|
||||
const pending = activateWithPasskey(handle, code, controller.signal);
|
||||
code = '';
|
||||
try {
|
||||
const confirmed = await pending;
|
||||
if (current !== generation) return;
|
||||
user = confirmed;
|
||||
notifySessionChanged();
|
||||
} catch {
|
||||
if (current !== generation) return;
|
||||
error = 'Registration completion could not be confirmed. If you completed the Passkey prompt, check the Passkey login page first. Otherwise re-enter the ID and code to restart. Ask your administrator to reissue if the code expired, was locked or was replaced.';
|
||||
} finally { if (current === generation) { code = ''; busy = false; controller = null; } }
|
||||
}
|
||||
function cancel() {
|
||||
++generation; controller?.abort(); controller = null; code = ''; busy = false;
|
||||
error = 'Registration stopped; completion is not confirmed. If you completed the Passkey prompt, check the Passkey login page first. Otherwise re-enter the ID and code to restart.';
|
||||
}
|
||||
onMount(() => {
|
||||
const clear = () => { ++generation; controller?.abort(); code = ''; busy = false; };
|
||||
window.addEventListener('pagehide', clear);
|
||||
return () => window.removeEventListener('pagehide', clear);
|
||||
});
|
||||
onDestroy(() => { ++generation; controller?.abort(); code = ''; });
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Activate account · Yoi</title></svelte:head>
|
||||
<div class="activation" data-sveltekit-preload-data="off">
|
||||
<h1>Activate account</h1>
|
||||
{#if user}
|
||||
<p role="status">Passkey registration and activation confirmed for {user.handle}. You are signed in.</p>
|
||||
<p>Use your Passkey for future logins. The activation code can no longer be used.</p>
|
||||
<a href="/account">Open Account</a>
|
||||
{:else}
|
||||
<p>Enter the ID and one-time code provided by your hosting administrator. You must register a Passkey before this account can be used.</p>
|
||||
<form onsubmit={(event) => { event.preventDefault(); void activate(); }} autocomplete="off">
|
||||
<label for="activation-handle">Account ID (handle)</label>
|
||||
<input id="activation-handle" bind:value={handle} required maxlength="128" autocomplete="off" autocapitalize="off" spellcheck="false" disabled={busy} />
|
||||
<label for="activation-code">Activation code</label>
|
||||
<input id="activation-code" type="password" bind:value={code} required maxlength="4096" autocomplete="off" autocapitalize="off" spellcheck="false" disabled={busy} aria-describedby="code-help" />
|
||||
<p id="code-help" class="muted">Use only this Server’s trusted HTTPS origin (or localhost). The code expires after 15 minutes or five incorrect attempts. It is not a password or an administrator setup token.</p>
|
||||
<div class="actions">
|
||||
<button class="primary" type="submit" disabled={busy || !handle.trim() || !code}>{busy ? 'Registering Passkey…' : 'Register Passkey and activate'}</button>
|
||||
{#if busy}<button type="button" onclick={cancel}>Cancel registration</button>{/if}
|
||||
</div>
|
||||
</form>
|
||||
{#if busy}<p role="status">Passkey registration pending. Account activation has not been confirmed.</p>{/if}
|
||||
{#if error}<p role="alert" class="error">{error}</p>{/if}
|
||||
<a href="/login">Already registered or completion uncertain? Log in with a Passkey</a>
|
||||
<details><summary>Restart or get a new code</summary><p>Allow the browser’s Passkey prompt and save a Passkey. Only a confirmed completion signs you in; checking the code alone gives no normal session, Workspace access or device approval. After cancellation or reload, enter the ID and code again. A correct code is not consumed by starting the prompt. If a request lost its response, try Passkey login before restarting. If no Passkey works, ask your administrator to reissue for the pending ID; reissue invalidates the old code and prompt. This works even when self-registration is disabled.</p></details>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.activation, form, details[open] { display: grid; align-content: start; gap: var(--space-4); min-width: 0; }
|
||||
.activation { max-width: 42rem; }
|
||||
h1, p { margin: 0; }
|
||||
h1 { font-size: var(--font-size-title); line-height: var(--line-height-title); }
|
||||
.muted { color: var(--text-muted); }
|
||||
.error { color: var(--danger); }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: var(--space-2); }
|
||||
input { width: 100%; min-width: 0; padding: var(--space-2); border: 1px solid var(--line-strong); border-radius: var(--radius-soft); background: var(--bg); color: var(--text-strong); font: inherit; }
|
||||
button { justify-self: start; min-height: 2.25rem; padding: var(--space-2) var(--space-3); border: 1px solid var(--line-strong); border-radius: var(--radius-soft); background: transparent; color: var(--text-strong); font: inherit; cursor: pointer; }
|
||||
button.primary { background: var(--text-strong); color: var(--bg); }
|
||||
button:hover:not(:disabled) { background: var(--interactive-hover); color: var(--text-strong); }
|
||||
button:disabled { opacity: .55; cursor: not-allowed; }
|
||||
a { color: var(--text-strong); text-decoration: underline; text-underline-offset: .15em; }
|
||||
a:hover, a:active { color: var(--text); }
|
||||
a:focus-visible, button:focus-visible, input:focus-visible, summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
</style>
|
||||
@@ -0,0 +1,101 @@
|
||||
// @vitest-environment happy-dom
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/svelte";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import Activate from "./+page.svelte";
|
||||
import {
|
||||
authUser,
|
||||
credentialFixture,
|
||||
registrationOptions,
|
||||
} from "#lib/workspace/auth/api.test-fixtures.ts";
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
function boundary(mode: "success" | "pending" | "cancel" | "unknown") {
|
||||
Object.defineProperty(window, "isSecureContext", {
|
||||
configurable: true,
|
||||
value: true,
|
||||
});
|
||||
const create = vi.fn(async () => {
|
||||
if (mode === "pending") return await new Promise<Credential>(() => {});
|
||||
if (mode === "cancel") throw new Error("private diagnostic");
|
||||
return credentialFixture();
|
||||
});
|
||||
vi.stubGlobal("navigator", { credentials: { create } });
|
||||
const fetcher = vi.fn<typeof fetch>(async (input) => {
|
||||
if (String(input) === "/api/auth/activation/options") {
|
||||
return Response.json(registrationOptions);
|
||||
}
|
||||
if (String(input) === "/api/auth/activation/complete") {
|
||||
if (mode === "unknown") throw new Error("private diagnostic");
|
||||
return Response.json({ user: authUser });
|
||||
}
|
||||
throw new Error("Unexpected endpoint");
|
||||
});
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
return { fetcher, create };
|
||||
}
|
||||
async function submit() {
|
||||
await fireEvent.input(screen.getByLabelText("Account ID (handle)"), {
|
||||
target: { value: " ALICE " },
|
||||
});
|
||||
await fireEvent.input(screen.getByLabelText("Activation code"), {
|
||||
target: { value: "synthetic-input-only" },
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Register Passkey and activate" }),
|
||||
);
|
||||
}
|
||||
test("activation page shows signed in only after complete confirmation and clears code before browser prompt", async () => {
|
||||
const { fetcher } = boundary("success");
|
||||
render(Activate);
|
||||
await submit();
|
||||
await screen.findByText(/registration and activation confirmed/);
|
||||
expect(fetcher.mock.calls.map(([input]) => input)).toEqual([
|
||||
"/api/auth/activation/options",
|
||||
"/api/auth/activation/complete",
|
||||
]);
|
||||
expect(screen.queryByLabelText("Activation code")).toBeNull();
|
||||
expect(JSON.parse(String(fetcher.mock.calls[0][1]?.body)).handle).toBe(
|
||||
"alice",
|
||||
);
|
||||
});
|
||||
test("pending ceremony never exposes activation or workspace actions and explicit cancel clears the input", async () => {
|
||||
const { fetcher } = boundary("pending");
|
||||
render(Activate);
|
||||
await submit();
|
||||
await screen.findByText(/activation has not been confirmed/);
|
||||
expect((screen.getByLabelText("Activation code") as HTMLInputElement).value)
|
||||
.toBe("");
|
||||
expect(screen.queryByText(/You are signed in/)).toBeNull();
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Cancel registration" }),
|
||||
);
|
||||
await screen.findByRole("alert");
|
||||
expect(fetcher.mock.calls.length).toBe(1);
|
||||
expect(
|
||||
(screen.getByLabelText("Activation code") as HTMLInputElement).disabled,
|
||||
).toBe(false);
|
||||
});
|
||||
for (const mode of ["cancel", "unknown"] as const) {
|
||||
test(`${mode} does not claim active or echo diagnostics and offers login-first recovery`, async () => {
|
||||
boundary(mode);
|
||||
render(Activate);
|
||||
await submit();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("alert").textContent).toContain("Passkey login")
|
||||
);
|
||||
expect(screen.getByRole("alert").textContent).not.toContain(
|
||||
"private diagnostic",
|
||||
);
|
||||
expect(screen.queryByText(/You are signed in/)).toBeNull();
|
||||
expect((screen.getByLabelText("Activation code") as HTMLInputElement).value)
|
||||
.toBe("");
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { BrowserAuth } from '#lib/workspace/auth/browser-session.svelte.ts';
|
||||
import { SESSION_CHANGED_EVENT } from '#lib/workspace/auth/hosting.ts';
|
||||
import { loginUrl } from '#lib/workspace/auth/routes.ts';
|
||||
import AccountNavigation from '#lib/workspace/auth/AccountNavigation.svelte';
|
||||
import WorkspaceCatalog from './WorkspaceCatalog.svelte';
|
||||
import { WorkspaceCreation } from './workspace-creation.svelte.ts';
|
||||
import '#lib/workspace/auth/auth-page.css';
|
||||
|
||||
const auth = new BrowserAuth();
|
||||
let creation = $state<WorkspaceCreation | null>(null);
|
||||
let mounted = false;
|
||||
$effect(() => {
|
||||
if (auth.session.kind !== 'ready') return;
|
||||
const actor = auth.session.actor;
|
||||
if (!actor) { creation = null; return; }
|
||||
if (creation?.accountId !== actor.account_id) {
|
||||
const draft: WorkspaceCreation = new WorkspaceCreation(actor.account_id, () =>
|
||||
mounted && creation === draft && auth.actor?.account_id === draft.accountId);
|
||||
creation = draft;
|
||||
}
|
||||
});
|
||||
onMount(() => {
|
||||
mounted = true;
|
||||
const refresh = () => { void auth.refresh(); };
|
||||
refresh();
|
||||
window.addEventListener('focus', refresh);
|
||||
window.addEventListener(SESSION_CHANGED_EVENT, refresh);
|
||||
return () => {
|
||||
mounted = false;
|
||||
auth.dispose();
|
||||
window.removeEventListener('focus', refresh);
|
||||
window.removeEventListener(SESSION_CHANGED_EVENT, refresh);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Dashboard · Yoi</title></svelte:head>
|
||||
<div class="auth-page" data-sveltekit-preload-data="off">
|
||||
<header><a href="/">Yoi</a><h1>Dashboard</h1></header>
|
||||
{#if auth.session.kind === 'loading'}
|
||||
<p class="muted" role="status">Loading session…</p>
|
||||
{:else if auth.session.kind === 'error'}
|
||||
<p class="error" role="alert">Your session could not be loaded. Login status is unknown.</p>
|
||||
<button type="button" onclick={() => auth.refresh()}>Retry session</button>
|
||||
<a href={loginUrl('/dashboard')}>Open login</a>
|
||||
{:else if auth.actor}
|
||||
{#key auth.actor.account_id}
|
||||
<AccountNavigation />
|
||||
{#if creation?.accountId === auth.actor.account_id}
|
||||
<WorkspaceCatalog {creation} />
|
||||
{/if}
|
||||
{/key}
|
||||
{:else}
|
||||
<p>Log in to select or create a Workspace.</p>
|
||||
<a class="entry-primary" href={loginUrl('/dashboard')}>Log in</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>header { display: grid; gap: var(--space-4); }</style>
|
||||
@@ -0,0 +1,198 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from "svelte";
|
||||
import {
|
||||
loadWorkspaceCatalog,
|
||||
type WorkspaceCatalogItem,
|
||||
} from "#lib/workspace/api/workspace-catalog.ts";
|
||||
|
||||
import type { WorkspaceCreation } from "./workspace-creation.svelte.ts";
|
||||
let { creation }: { creation: WorkspaceCreation } = $props();
|
||||
let disposed = false;
|
||||
let request = 0;
|
||||
let workspaces = $state<WorkspaceCatalogItem[]>([]);
|
||||
let catalogError = $state<string | null>(null);
|
||||
let refreshing = $state(false);
|
||||
|
||||
onMount(() => {
|
||||
if (window.location.hash === "#workspace-create-title") creation.open = true;
|
||||
const hashChanged = () => { if (window.location.hash === "#workspace-create-title") creation.open = true; };
|
||||
window.addEventListener("hashchange", hashChanged);
|
||||
void refreshCatalog();
|
||||
return () => { disposed = true; ++request; window.removeEventListener("hashchange", hashChanged); };
|
||||
});
|
||||
|
||||
async function refreshCatalog() {
|
||||
const current = ++request;
|
||||
refreshing = true;
|
||||
workspaces = [];
|
||||
catalogError = null;
|
||||
try {
|
||||
const result = await loadWorkspaceCatalog(fetch);
|
||||
if (!disposed && current === request) workspaces = result;
|
||||
} catch {
|
||||
if (!disposed && current === request) catalogError = "Workspaces could not be loaded. Check your session or retry.";
|
||||
} finally {
|
||||
if (!disposed && current === request) refreshing = false;
|
||||
}
|
||||
}
|
||||
|
||||
function submitCreation(event: SubmitEvent) {
|
||||
event.preventDefault();
|
||||
void creation.submit();
|
||||
}
|
||||
|
||||
function formatUpdated(value: string): string {
|
||||
const timestamp = Date.parse(value);
|
||||
return Number.isNaN(timestamp)
|
||||
? value
|
||||
: new Intl.DateTimeFormat(undefined, {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(timestamp);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="workspace-catalog-shell">
|
||||
<section class="workspace-catalog-heading">
|
||||
<div>
|
||||
<p>Choose a Workspace to open its dashboard. Workspace settings are managed inside each Workspace.</p>
|
||||
</div>
|
||||
<button class="workspace-secondary-action" onclick={refreshCatalog} disabled={refreshing}>
|
||||
{refreshing ? "Refreshing…" : "Refresh"}
|
||||
</button>
|
||||
</section>
|
||||
|
||||
{#if catalogError}
|
||||
<div class="workspace-catalog-alert" role="alert">
|
||||
{catalogError}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<section aria-labelledby="workspace-list-title">
|
||||
<h2 id="workspace-list-title">Choose Workspace</h2>
|
||||
{#if refreshing}
|
||||
<p role="status">Loading Workspaces…</p>
|
||||
{:else if catalogError}
|
||||
<a href="/account">Check Account</a>
|
||||
{:else if workspaces.length === 0}
|
||||
<div class="workspace-empty-state">
|
||||
<strong>No accessible Workspaces</strong>
|
||||
<p>Create the first Workspace if you have Backend permission.</p>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="workspace-catalog-grid">
|
||||
{#each workspaces as workspace (workspace.workspace_id)}
|
||||
<a
|
||||
class="workspace-catalog-card"
|
||||
href={`/w/${encodeURIComponent(workspace.workspace_id)}`}
|
||||
>
|
||||
<span class="workspace-card-heading">
|
||||
<strong>{workspace.display_name}</strong>
|
||||
<span class:workspace-state-active={workspace.state === "active"}>
|
||||
{workspace.state}
|
||||
</span>
|
||||
</span>
|
||||
{#if workspace.repositories[0]}
|
||||
<span class="workspace-repository-summary">
|
||||
{workspace.repositories[0].repository_key}
|
||||
<small>
|
||||
{workspace.repositories[0].default_selector ?? "repository default"} ·
|
||||
{workspace.repositories[0].kind}
|
||||
</small>
|
||||
</span>
|
||||
{:else if workspace.repository_error}
|
||||
<small>Repository summary unavailable</small>
|
||||
{:else}
|
||||
<small>No repositories</small>
|
||||
{/if}
|
||||
<small>Updated {formatUpdated(workspace.updated_at)}</small>
|
||||
</a>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<details class="workspace-create-panel" bind:open={creation.open}>
|
||||
<summary id="workspace-create-title">Create Workspace</summary>
|
||||
<div class="workspace-create-content">
|
||||
<div>
|
||||
<p>You can add repositories later in Workspace Settings.</p>
|
||||
</div>
|
||||
{#if creation.createdWorkspaceId}
|
||||
<p role="status">Workspace creation confirmed.</p>
|
||||
<a href={`/w/${encodeURIComponent(creation.createdWorkspaceId)}`}>Open created Workspace</a>
|
||||
{:else}
|
||||
<form onsubmit={submitCreation}>
|
||||
<label>
|
||||
Workspace display name
|
||||
<input bind:value={creation.displayName} required autocomplete="off" />
|
||||
</label>
|
||||
<label class="workspace-repository-toggle">
|
||||
<input type="checkbox" bind:checked={creation.includeRepository} aria-controls={creation.includeRepository ? "initial-repository-fields" : undefined} />
|
||||
Add an initial repository (optional)
|
||||
</label>
|
||||
{#if creation.includeRepository}
|
||||
<div id="initial-repository-fields" class="workspace-repository-fields">
|
||||
<p>
|
||||
Repository sources are interpreted by Backend authority. Supported Git sources are absolute local paths, file://, ssh://, http(s)://, and user@host:path; Browser-local paths and embedded credentials are not authority. Plain HTTP is unencrypted, so prefer HTTPS or SSH.
|
||||
</p>
|
||||
<label>
|
||||
Initial repository absolute path or URI
|
||||
<input bind:value={creation.repositoryUri} required autocomplete="off" />
|
||||
</label>
|
||||
<div class="workspace-create-row">
|
||||
<label>
|
||||
Repository key
|
||||
<input bind:value={creation.repositoryKey} required pattern="[a-z0-9]|[a-z0-9](?:[a-z0-9]|-)*[a-z0-9]" maxlength="64" autocomplete="off" />
|
||||
</label>
|
||||
<label>
|
||||
Default ref
|
||||
<input bind:value={creation.defaultRef} placeholder="repository default" autocomplete="off" />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
{#if creation.error}
|
||||
<div class="workspace-catalog-alert" role="alert">{creation.error}</div>
|
||||
{/if}
|
||||
<button class="workspace-primary-action" type="submit" disabled={creation.creating}>
|
||||
{creation.creating ? "Creating…" : creation.error ? "Retry creation" : "Create Workspace"}
|
||||
</button>
|
||||
</form>
|
||||
{/if}
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.workspace-catalog-shell { display: grid; gap: var(--space-5); min-width: 0; }
|
||||
.workspace-catalog-heading, .workspace-card-heading { display: flex; justify-content: space-between; align-items: center; gap: var(--space-4); }
|
||||
h2 { margin: 0 0 var(--space-3); font-size: var(--font-size-body); }
|
||||
p { margin: 0; color: var(--text-muted); }
|
||||
.workspace-catalog-grid { display: grid; }
|
||||
.workspace-catalog-card { display: grid; gap: var(--space-2); padding-block: var(--space-4); border-bottom: 1px solid var(--line); min-width: 0; overflow-wrap: anywhere; text-decoration: none; color: var(--text-strong); }
|
||||
.workspace-catalog-card:hover { background: var(--interactive-hover); }
|
||||
.workspace-card-heading strong { min-width: 0; }
|
||||
.workspace-card-heading > span { flex: 0 0 auto; font-size: var(--font-size-compact); color: var(--text-muted); }
|
||||
.workspace-card-heading > .workspace-state-active { color: var(--success); }
|
||||
small { color: var(--text-muted); font-size: var(--font-size-compact); }
|
||||
.workspace-repository-summary { display: grid; gap: var(--space-1); }
|
||||
.workspace-empty-state { padding-block: var(--space-4); }
|
||||
.workspace-create-panel, .workspace-create-content, form { min-width: 0; }
|
||||
summary { cursor: pointer; font-weight: 700; }
|
||||
.workspace-create-content, form, .workspace-repository-fields { display: grid; gap: var(--space-4); }
|
||||
.workspace-create-content { padding-block: var(--space-4); }
|
||||
label { display: grid; gap: var(--space-2); min-width: 0; }
|
||||
input { width: 100%; min-width: 0; padding: var(--space-2) var(--space-3); border: 1px solid var(--line-strong); border-radius: var(--radius-soft); background: var(--bg); color: var(--text-strong); font: inherit; }
|
||||
.workspace-repository-toggle { display: flex; align-items: center; gap: var(--space-2); }
|
||||
input[type=checkbox] { width: auto; }
|
||||
.workspace-create-row { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(180px, 100%), 1fr)); gap: var(--space-4); }
|
||||
button { justify-self: start; min-height: 2.25rem; padding: var(--space-2) var(--space-3); border: 1px solid var(--line-strong); border-radius: var(--radius-soft); background: transparent; color: var(--text-strong); font: inherit; cursor: pointer; }
|
||||
.workspace-primary-action { background: var(--text-strong); color: var(--bg); }
|
||||
button:hover:not(:disabled), summary:hover { background: var(--interactive-hover); color: var(--text-strong); }
|
||||
:is(a, button, summary):active { background: var(--interactive-selected); color: var(--text-strong); }
|
||||
button:disabled { opacity: .55; cursor: not-allowed; }
|
||||
:is(a, button, input, summary):focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
.workspace-catalog-alert { color: var(--danger); }
|
||||
@media (max-width: 760px) { .workspace-catalog-heading { align-items: start; flex-direction: column; } }
|
||||
</style>
|
||||
@@ -0,0 +1,63 @@
|
||||
import { goto } from "$app/navigation";
|
||||
import {
|
||||
createOperationKey,
|
||||
createWorkspace,
|
||||
type CreateWorkspaceRequest,
|
||||
creationErrorMessage,
|
||||
} from "#lib/workspace/api/workspace-catalog.ts";
|
||||
|
||||
// 同一actorのsession再確認はformの寿命ではない。これは権限のcacheではなく、
|
||||
// dashboard内だけに保持する下書き・要求・確定結果。表示/操作/遷移は毎回live sessionで遮断する。
|
||||
export class WorkspaceCreation {
|
||||
open = $state(false);
|
||||
displayName = $state("");
|
||||
includeRepository = $state(false);
|
||||
repositoryKey = $state("");
|
||||
repositoryUri = $state("");
|
||||
defaultRef = $state("");
|
||||
creating = $state(false);
|
||||
error = $state<string | null>(null);
|
||||
createdWorkspaceId = $state<string | null>(null);
|
||||
private lastSubmission:
|
||||
| { signature: string; request: CreateWorkspaceRequest }
|
||||
| null = null;
|
||||
|
||||
constructor(readonly accountId: string, private canOperate: () => boolean) {}
|
||||
|
||||
async submit(): Promise<void> {
|
||||
if (this.creating || this.createdWorkspaceId || !this.canOperate()) return;
|
||||
const normalized = {
|
||||
display_name: this.displayName.trim(),
|
||||
repository: this.includeRepository
|
||||
? {
|
||||
repository_key: this.repositoryKey.trim(),
|
||||
uri: this.repositoryUri.trim(),
|
||||
default_ref: this.defaultRef.trim() || null,
|
||||
}
|
||||
: null,
|
||||
};
|
||||
const signature = JSON.stringify(normalized);
|
||||
const request = this.lastSubmission?.signature === signature
|
||||
? this.lastSubmission.request
|
||||
: { operation_key: createOperationKey(), ...normalized };
|
||||
this.lastSubmission = { signature, request };
|
||||
this.creating = true;
|
||||
this.error = null;
|
||||
try {
|
||||
const response = await createWorkspace(fetch, request);
|
||||
this.createdWorkspaceId = response.workspace.workspace_id;
|
||||
} catch (error) {
|
||||
this.error = creationErrorMessage(error);
|
||||
} finally {
|
||||
this.creating = false;
|
||||
}
|
||||
// 再確認中の成功を匿名/別actorの遷移にしない。同一actor復帰後は確定結果への明示linkを出す。
|
||||
if (this.createdWorkspaceId && this.canOperate()) {
|
||||
try {
|
||||
await goto(`/w/${encodeURIComponent(this.createdWorkspaceId)}`);
|
||||
} catch {
|
||||
/* 作成成功は確定済み。遷移失敗でも新しい作成要求には変えない。 */
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,389 @@
|
||||
// @vitest-environment happy-dom
|
||||
// UI→本番clientを接続しHTTPだけ代替する。実Serverの認証・認可・SQLiteは共有E2Eが所有する。
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/svelte";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import Home from "./+page.svelte";
|
||||
import Dashboard from "./dashboard/+page.svelte";
|
||||
import { authUser } from "#lib/workspace/auth/api.test-fixtures.ts";
|
||||
import {
|
||||
createdWorkspace,
|
||||
creationResponse,
|
||||
} from "#lib/workspace/api/workspace-catalog.test-fixtures.ts";
|
||||
import { SESSION_CHANGED_EVENT } from "#lib/workspace/auth/hosting.ts";
|
||||
import { goto } from "$app/navigation";
|
||||
vi.mock("$app/navigation", () => ({ goto: vi.fn(async () => {}) }));
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
function deferred<T>() {
|
||||
let resolve!: (value: T) => void;
|
||||
const promise = new Promise<T>((done) => {
|
||||
resolve = done;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
function boundary() {
|
||||
const state = {
|
||||
signedIn: false,
|
||||
accountId: authUser.account_id,
|
||||
creationGate: null as ReturnType<typeof deferred<Response>> | null,
|
||||
whoamiStatus: 200,
|
||||
policy: true as boolean | "error" | "loading",
|
||||
admin: false,
|
||||
catalogStatus: 200,
|
||||
count: 0,
|
||||
createStatus: 200,
|
||||
sessionGate: null as ReturnType<typeof deferred<Response>> | null,
|
||||
catalogGate: null as ReturnType<typeof deferred<Response>> | null,
|
||||
};
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(
|
||||
async (input, init) => {
|
||||
const path = String(input);
|
||||
if (path === "/api/auth/whoami") {
|
||||
if (state.sessionGate) return state.sessionGate.promise;
|
||||
return state.whoamiStatus === 200
|
||||
? Response.json({
|
||||
actor: state.signedIn
|
||||
? {
|
||||
...authUser,
|
||||
account_id: state.accountId,
|
||||
auth_method: "browser_session",
|
||||
}
|
||||
: null,
|
||||
})
|
||||
: new Response(null, { status: state.whoamiStatus });
|
||||
}
|
||||
if (path === "/api/auth/registration-policy") {
|
||||
if (state.policy === "loading") return new Promise<Response>(() => {});
|
||||
return state.policy === "error"
|
||||
? new Response(null, { status: 503 })
|
||||
: Response.json({ self_registration_enabled: state.policy });
|
||||
}
|
||||
if (path === "/api/hosting/access") {
|
||||
return Response.json({ is_admin: state.admin });
|
||||
}
|
||||
if (init?.method === "POST") {
|
||||
if (state.creationGate) return state.creationGate.promise;
|
||||
return state.createStatus === 200
|
||||
? Response.json(creationResponse())
|
||||
: new Response(null, { status: state.createStatus });
|
||||
}
|
||||
if (path.startsWith("/api/workspaces?")) {
|
||||
if (state.catalogGate) return state.catalogGate.promise;
|
||||
return state.catalogStatus === 200
|
||||
? Response.json(
|
||||
Array.from(
|
||||
{ length: state.count },
|
||||
(_, i) => ({
|
||||
...createdWorkspace,
|
||||
workspace_id: `space-${i}`,
|
||||
display_name: `Team ${i}`,
|
||||
}),
|
||||
),
|
||||
)
|
||||
: new Response(null, { status: state.catalogStatus });
|
||||
}
|
||||
if (path.includes("/repositories")) {
|
||||
return Response.json({
|
||||
workspace_id: "fixture",
|
||||
items: [],
|
||||
source: "workspace-control-plane",
|
||||
diagnostics: [],
|
||||
});
|
||||
}
|
||||
throw new Error(`Unexpected request ${path}`);
|
||||
},
|
||||
);
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
return {
|
||||
state,
|
||||
fetcher,
|
||||
workspaceCalls: () =>
|
||||
fetcher.mock.calls.filter(([path]) =>
|
||||
String(path).startsWith("/api/workspaces")
|
||||
),
|
||||
};
|
||||
}
|
||||
for (const policy of [true, false, "error", "loading"] as const) {
|
||||
test(`public home keeps login and exposes registration only for confirmed allowed policy: ${policy}`, async () => {
|
||||
const { state, workspaceCalls } = boundary();
|
||||
state.policy = policy;
|
||||
state.catalogStatus = 401;
|
||||
render(Home);
|
||||
expect(
|
||||
(await screen.findByRole("link", { name: "Log in" })).getAttribute(
|
||||
"href",
|
||||
),
|
||||
).toBe("/login");
|
||||
if (policy === true) {
|
||||
expect(
|
||||
(await screen.findByRole("link", { name: "Create a new account" }))
|
||||
.getAttribute("href"),
|
||||
).toBe("/register?returnTo=%2Fdashboard");
|
||||
} else {
|
||||
await screen.findByText(
|
||||
policy === false
|
||||
? /registration is disabled/
|
||||
: policy === "error"
|
||||
? /availability is unknown/
|
||||
: /Checking registration/,
|
||||
);
|
||||
expect(screen.queryByRole("link", { name: "Create a new account" }))
|
||||
.toBeNull();
|
||||
}
|
||||
expect(workspaceCalls()).toEqual([]);
|
||||
expect(screen.queryByText(/No accessible Workspaces/)).toBeNull();
|
||||
});
|
||||
}
|
||||
test("whoami loading and failure never expose anonymous actions or Workspace content; retry confirms actor", async () => {
|
||||
const { state, workspaceCalls } = boundary();
|
||||
state.sessionGate = deferred<Response>();
|
||||
render(Home);
|
||||
await screen.findByText("Loading session…");
|
||||
expect(screen.queryByRole("link")).toBeNull();
|
||||
state.sessionGate.resolve(new Response(null, { status: 503 }));
|
||||
await screen.findByRole("alert");
|
||||
expect(screen.queryByRole("link", { name: "Log in" })).toBeNull();
|
||||
expect(screen.queryByRole("link", { name: "Create a new account" }))
|
||||
.toBeNull();
|
||||
state.sessionGate = null;
|
||||
state.signedIn = true;
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Retry session" }));
|
||||
expect(
|
||||
(await screen.findByRole("link", { name: "Open dashboard" })).getAttribute(
|
||||
"href",
|
||||
),
|
||||
).toBe("/dashboard");
|
||||
expect(workspaceCalls()).toEqual([]);
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
});
|
||||
for (const admin of [false, true]) {
|
||||
test(`confirmed actor account/hosting navigation reflects live admin=${admin}`, async () => {
|
||||
const { state } = boundary();
|
||||
state.signedIn = true;
|
||||
state.admin = admin;
|
||||
render(Home);
|
||||
await screen.findByRole("link", { name: "Account" });
|
||||
if (admin) await screen.findByRole("link", { name: "Hosting settings" });
|
||||
else {expect(screen.queryByRole("link", { name: "Hosting settings" }))
|
||||
.toBeNull();}
|
||||
state.signedIn = false;
|
||||
await fireEvent(window, new Event(SESSION_CHANGED_EVENT));
|
||||
await screen.findByRole("link", { name: "Log in" });
|
||||
expect(screen.queryByRole("link", { name: "Hosting settings" })).toBeNull();
|
||||
});
|
||||
}
|
||||
for (const count of [0, 1, 3]) {
|
||||
test(`dashboard ${count} Workspaces requires explicit selection and offers creation`, async () => {
|
||||
const { state } = boundary();
|
||||
state.signedIn = true;
|
||||
state.count = count;
|
||||
render(Dashboard);
|
||||
if (count === 0) await screen.findByText("No accessible Workspaces");
|
||||
else {
|
||||
await screen.findByRole("link", { name: /Team 0/ });
|
||||
expect(screen.getAllByRole("link", { name: /Team \d/ })).toHaveLength(
|
||||
count,
|
||||
);
|
||||
expect(screen.getByRole("link", { name: /Team 0/ }).getAttribute("href"))
|
||||
.toBe("/w/space-0");
|
||||
}
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
await fireEvent.click(
|
||||
screen.getByText("Create Workspace", { selector: "summary" }),
|
||||
);
|
||||
await fireEvent.input(screen.getByLabelText("Workspace display name"), {
|
||||
target: { value: "New Workspace" },
|
||||
});
|
||||
await fireEvent.submit(
|
||||
screen.getByRole("button", { name: "Create Workspace" }).closest("form")!,
|
||||
);
|
||||
await waitFor(() => expect(goto).toHaveBeenCalledWith("/w/home-empty"));
|
||||
});
|
||||
}
|
||||
for (const status of [401, 503]) {
|
||||
test(`dashboard catalog failure ${status} is not empty; account and creation recovery remain`, async () => {
|
||||
const { state } = boundary();
|
||||
state.signedIn = true;
|
||||
state.catalogStatus = status;
|
||||
render(Dashboard);
|
||||
await screen.findByRole("alert");
|
||||
expect(screen.queryByText("No accessible Workspaces")).toBeNull();
|
||||
expect(screen.getByRole("link", { name: "Account" })).toBeTruthy();
|
||||
expect(screen.getByRole("button", { name: "Refresh" })).toBeTruthy();
|
||||
});
|
||||
}
|
||||
test("dashboard removes private content on session change and ignores old catalog completion", async () => {
|
||||
const { state } = boundary();
|
||||
state.signedIn = true;
|
||||
state.catalogGate = deferred<Response>();
|
||||
render(Dashboard);
|
||||
await screen.findByText("Loading Workspaces…");
|
||||
state.signedIn = false;
|
||||
await fireEvent(window, new Event(SESSION_CHANGED_EVENT));
|
||||
await screen.findByText("Log in to select or create a Workspace.");
|
||||
state.catalogGate.resolve(Response.json([createdWorkspace]));
|
||||
expect(screen.queryByText("New Workspace")).toBeNull();
|
||||
expect(screen.queryByLabelText("Workspace display name")).toBeNull();
|
||||
});
|
||||
test("dashboard does not contact catalog for anonymous or unknown session", async () => {
|
||||
const { state, workspaceCalls } = boundary();
|
||||
const view = render(Dashboard);
|
||||
await screen.findByRole("link", { name: "Log in" });
|
||||
expect(workspaceCalls()).toEqual([]);
|
||||
view.unmount();
|
||||
state.whoamiStatus = 503;
|
||||
render(Dashboard);
|
||||
await screen.findByRole("alert");
|
||||
expect(workspaceCalls()).toEqual([]);
|
||||
});
|
||||
|
||||
async function openCreation() {
|
||||
await screen.findByText("No accessible Workspaces");
|
||||
await fireEvent.click(
|
||||
screen.getByText("Create Workspace", { selector: "summary" }),
|
||||
);
|
||||
await fireEvent.input(screen.getByLabelText("Workspace display name"), {
|
||||
target: { value: "Retained draft" },
|
||||
});
|
||||
}
|
||||
const submitCreation = async (name = "Create Workspace") => {
|
||||
await fireEvent.submit(screen.getByRole("button", { name }).closest("form")!);
|
||||
};
|
||||
const creationBodies = (fetcher: ReturnType<typeof boundary>["fetcher"]) =>
|
||||
fetcher.mock.calls.filter(([, init]) => init?.method === "POST").map((
|
||||
[, init],
|
||||
) => JSON.parse(String(init!.body)));
|
||||
|
||||
test("same actor focus retains creation disclosure, repository fields, errors and exact retry key through unknown session", async () => {
|
||||
const { state, fetcher } = boundary();
|
||||
state.signedIn = true;
|
||||
state.createStatus = 503;
|
||||
render(Dashboard);
|
||||
await openCreation();
|
||||
await fireEvent.click(
|
||||
screen.getByRole("checkbox", {
|
||||
name: "Add an initial repository (optional)",
|
||||
}),
|
||||
);
|
||||
await fireEvent.input(screen.getByLabelText("Repository key"), {
|
||||
target: { value: "main" },
|
||||
});
|
||||
await fireEvent.input(
|
||||
screen.getByLabelText("Initial repository absolute path or URI"),
|
||||
{ target: { value: "/srv/main" } },
|
||||
);
|
||||
await submitCreation();
|
||||
await screen.findByRole("alert");
|
||||
state.sessionGate = deferred<Response>();
|
||||
await fireEvent(window, new Event("focus"));
|
||||
await screen.findByText("Loading session…");
|
||||
expect(screen.queryByLabelText("Workspace display name")).toBeNull();
|
||||
state.sessionGate.resolve(
|
||||
Response.json({ actor: { ...authUser, auth_method: "browser_session" } }),
|
||||
);
|
||||
expect(
|
||||
(await screen.findByLabelText("Workspace display name") as HTMLInputElement)
|
||||
.value,
|
||||
).toBe("Retained draft");
|
||||
expect((screen.getByLabelText("Repository key") as HTMLInputElement).value)
|
||||
.toBe("main");
|
||||
expect(
|
||||
(screen.getByLabelText(
|
||||
"Initial repository absolute path or URI",
|
||||
) as HTMLInputElement).value,
|
||||
).toBe("/srv/main");
|
||||
await submitCreation("Retry creation");
|
||||
await screen.findByRole("alert");
|
||||
expect(creationBodies(fetcher)).toHaveLength(2);
|
||||
expect(creationBodies(fetcher)[0]).toEqual(creationBodies(fetcher)[1]);
|
||||
});
|
||||
|
||||
test("pending create remains single across focus and a success during unknown session becomes an explicit confirmed-result link", async () => {
|
||||
const { state, fetcher } = boundary();
|
||||
state.signedIn = true;
|
||||
state.creationGate = deferred<Response>();
|
||||
render(Dashboard);
|
||||
await openCreation();
|
||||
await submitCreation();
|
||||
await screen.findByRole("button", { name: "Creating…" });
|
||||
state.sessionGate = deferred<Response>();
|
||||
await fireEvent(window, new Event("focus"));
|
||||
await screen.findByText("Loading session…");
|
||||
const response = Response.json(creationResponse());
|
||||
state.creationGate.resolve(response);
|
||||
await waitFor(() => expect(response.bodyUsed).toBe(true));
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
state.sessionGate.resolve(
|
||||
Response.json({ actor: { ...authUser, auth_method: "browser_session" } }),
|
||||
);
|
||||
expect(
|
||||
(await screen.findByRole("link", { name: "Open created Workspace" }))
|
||||
.getAttribute("href"),
|
||||
).toBe("/w/home-empty");
|
||||
expect(screen.queryByRole("button", { name: "Create Workspace" })).toBeNull();
|
||||
expect(creationBodies(fetcher)).toHaveLength(1);
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("same actor session error retains draft privately until retry; confirmed different actor gets a fresh draft", async () => {
|
||||
const { state } = boundary();
|
||||
state.signedIn = true;
|
||||
render(Dashboard);
|
||||
await openCreation();
|
||||
state.whoamiStatus = 503;
|
||||
await fireEvent(window, new Event(SESSION_CHANGED_EVENT));
|
||||
await screen.findByRole("alert");
|
||||
expect(screen.queryByLabelText("Workspace display name")).toBeNull();
|
||||
state.whoamiStatus = 200;
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Retry session" }));
|
||||
expect(
|
||||
(await screen.findByLabelText("Workspace display name") as HTMLInputElement)
|
||||
.value,
|
||||
).toBe("Retained draft");
|
||||
state.accountId = "different-account";
|
||||
await fireEvent(window, new Event(SESSION_CHANGED_EVENT));
|
||||
await screen.findByText("No accessible Workspaces");
|
||||
await fireEvent.click(
|
||||
screen.getByText("Create Workspace", { selector: "summary" }),
|
||||
);
|
||||
expect(
|
||||
(screen.getByLabelText("Workspace display name") as HTMLInputElement).value,
|
||||
).toBe("");
|
||||
});
|
||||
|
||||
test("late creation result after confirmed logout or actor change never navigates another actor", async () => {
|
||||
const { state } = boundary();
|
||||
state.signedIn = true;
|
||||
state.creationGate = deferred<Response>();
|
||||
render(Dashboard);
|
||||
await openCreation();
|
||||
await submitCreation();
|
||||
await screen.findByRole("button", { name: "Creating…" });
|
||||
state.signedIn = false;
|
||||
await fireEvent(window, new Event(SESSION_CHANGED_EVENT));
|
||||
await screen.findByRole("link", { name: "Log in" });
|
||||
const response = Response.json(creationResponse());
|
||||
state.creationGate.resolve(response);
|
||||
await waitFor(() => expect(response.bodyUsed).toBe(true));
|
||||
expect(goto).not.toHaveBeenCalled();
|
||||
state.signedIn = true;
|
||||
await fireEvent(window, new Event(SESSION_CHANGED_EVENT));
|
||||
await screen.findByText("No accessible Workspaces");
|
||||
expect(screen.queryByRole("link", { name: "Open created Workspace" }))
|
||||
.toBeNull();
|
||||
await fireEvent.click(
|
||||
screen.getByText("Create Workspace", { selector: "summary" }),
|
||||
);
|
||||
expect(
|
||||
(screen.getByLabelText("Workspace display name") as HTMLInputElement).value,
|
||||
).toBe("");
|
||||
});
|
||||
@@ -0,0 +1,127 @@
|
||||
<script lang="ts">
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import type { ActivationCodeResponse } from '#lib/generated/auth-api.ts';
|
||||
import { loadWhoami } from '#lib/workspace/auth/api.ts';
|
||||
import { HostingRequestError, loadHostingAccess, SESSION_CHANGED_EVENT } from '#lib/workspace/auth/hosting.ts';
|
||||
import { issueActivationCode, normalizeActivationHandle } from '#lib/workspace/auth/hosting-accounts.ts';
|
||||
|
||||
let viewState = $state<'loading' | 'anonymous' | 'denied' | 'error' | 'ready'>('loading');
|
||||
let handle = $state('');
|
||||
let operation = $state<'create' | 'reissue'>('create');
|
||||
let issued = $state<ActivationCodeResponse | null>(null);
|
||||
let busy = $state(false);
|
||||
let error = $state('');
|
||||
let generation = 0;
|
||||
let controller = new AbortController();
|
||||
let expiryTimer: ReturnType<typeof setTimeout> | undefined;
|
||||
function clearIssued() { issued = null; clearTimeout(expiryTimer); }
|
||||
async function load() {
|
||||
const current = ++generation;
|
||||
controller.abort(); controller = new AbortController();
|
||||
clearIssued(); busy = false; viewState = 'loading'; error = '';
|
||||
try {
|
||||
const { actor } = await loadWhoami();
|
||||
if (current !== generation) return;
|
||||
if (!actor) { viewState = 'anonymous'; return; }
|
||||
const access = await loadHostingAccess();
|
||||
if (current !== generation) return;
|
||||
viewState = access.is_admin ? 'ready' : 'denied';
|
||||
} catch { if (current === generation) viewState = 'error'; }
|
||||
}
|
||||
async function issue() {
|
||||
if (busy || viewState !== 'ready') return;
|
||||
clearIssued(); error = '';
|
||||
try { handle = normalizeActivationHandle(handle); }
|
||||
catch { error = 'Use 1–64 ASCII letters, numbers, hyphens or underscores for the account ID.'; return; }
|
||||
const current = generation;
|
||||
busy = true;
|
||||
try {
|
||||
// 表示済みaccessを永続権限と見なさず、操作直前にもlive確認する。
|
||||
const access = await loadHostingAccess();
|
||||
if (current !== generation) return;
|
||||
if (!access.is_admin) { viewState = 'denied'; return; }
|
||||
const result = await issueActivationCode(handle, operation === 'reissue', controller.signal);
|
||||
if (current !== generation) return;
|
||||
issued = result;
|
||||
expiryTimer = setTimeout(clearIssued, Math.max(0, Math.min(900000, Date.parse(result.expires_at) - Date.now())));
|
||||
} catch (err) {
|
||||
if (current !== generation) return;
|
||||
if (err instanceof HostingRequestError && (err.status === 401 || err.status === 403)) {
|
||||
viewState = err.status === 401 ? 'anonymous' : 'denied';
|
||||
} else {
|
||||
error = 'Code issuance could not be confirmed. Do not repeat account creation automatically. If the recipient completed registration, check Passkey login first. Otherwise verify the ID and explicitly reissue a code for the pending account. Active accounts cannot be reissued.';
|
||||
}
|
||||
} finally { if (current === generation) busy = false; }
|
||||
}
|
||||
onMount(() => {
|
||||
void load();
|
||||
const refresh = () => { void load(); };
|
||||
const clearPage = () => { ++generation; controller.abort(); clearIssued(); busy = false; };
|
||||
const restorePage = (event: PageTransitionEvent) => { if (event.persisted) void load(); };
|
||||
window.addEventListener(SESSION_CHANGED_EVENT, refresh);
|
||||
window.addEventListener('pagehide', clearPage);
|
||||
window.addEventListener('pageshow', restorePage);
|
||||
return () => { window.removeEventListener(SESSION_CHANGED_EVENT, refresh); window.removeEventListener('pagehide', clearPage); window.removeEventListener('pageshow', restorePage); };
|
||||
});
|
||||
onDestroy(() => { ++generation; controller.abort(); clearIssued(); });
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Hosting accounts · Yoi</title></svelte:head>
|
||||
<div class="accounts" data-sveltekit-preload-data="off">
|
||||
<h1>Hosting accounts</h1>
|
||||
{#if viewState === 'loading'}
|
||||
<p role="status">Checking hosting access…</p>
|
||||
{:else if viewState === 'anonymous'}
|
||||
<p>Sign in with a hosting administrator account to issue activation codes.</p>
|
||||
<a href="/account">Open Account</a>
|
||||
{:else if viewState === 'denied'}
|
||||
<p>Hosting administrator access is required.</p>
|
||||
{:else if viewState === 'error'}
|
||||
<p role="alert" class="error">Hosting access could not be checked.</p>
|
||||
<button type="button" onclick={load}>Retry access check</button>
|
||||
{:else}
|
||||
<p>Create a pending, ordinary user with an ID only. No email or existing Passkey is needed, even when self-registration is disabled.</p>
|
||||
{#if issued}
|
||||
<section aria-labelledby="issued-heading">
|
||||
<h2 id="issued-heading">Activation code issued</h2>
|
||||
<dl><dt>Account ID (handle)</dt><dd>{issued.user.handle}</dd><dt>Expires</dt><dd>{new Date(issued.expires_at).toLocaleString()}</dd><dt>One-time code</dt><dd class="secret">{issued.code}</dd></dl>
|
||||
<p>This code is shown only here and disappears when hidden, expired or you leave. Privately give it and the ID to the recipient. Do not put it in a URL, screenshot, log or support message.</p>
|
||||
<p>Ask the recipient to open <a href="/activate">/activate</a> on this Server and register a Passkey within 15 minutes. The pending account is not active until registration completes.</p>
|
||||
<button type="button" onclick={clearIssued}>Hide code</button>
|
||||
</section>
|
||||
{:else}
|
||||
<form onsubmit={(event) => { event.preventDefault(); void issue(); }} autocomplete="off">
|
||||
<label for="account-operation">Operation</label>
|
||||
<select id="account-operation" bind:value={operation} disabled={busy}><option value="create">Create account and issue code</option><option value="reissue">Reissue for a pending account</option></select>
|
||||
<label for="account-handle">Account ID (handle)</label>
|
||||
<input id="account-handle" bind:value={handle} required maxlength="128" autocomplete="off" autocapitalize="off" spellcheck="false" disabled={busy} aria-describedby="handle-help" />
|
||||
<p id="handle-help" class="muted">1–64 ASCII letters, numbers, hyphens or underscores. Surrounding spaces are removed and letters become lowercase.</p>
|
||||
{#if operation === 'reissue'}<p>Reissuing invalidates the old code and any in-progress registration. It cannot replace a Passkey or recover an active account.</p>{/if}
|
||||
<button class="primary" type="submit" disabled={busy || !handle.trim()}>{busy ? 'Issuing code…' : operation === 'create' ? 'Create account and issue code' : 'Reissue activation code'}</button>
|
||||
</form>
|
||||
{/if}
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
<details><summary>Issuance and recovery steps</summary><p>Create a new ID, deliver the code privately, then ask the recipient to register at /activate. Codes expire after 15 minutes or five incorrect attempts. Options alone do not consume a correct code. After a cancelled prompt or reload, re-enter ID and code. If expired, locked or lost, choose reissue for the same pending ID and deliver only the new code. Ask the recipient to check Passkey login first if completion was uncertain. After activation use Passkey login, not the code.</p></details>
|
||||
<a href="/hosting/settings">Hosting settings</a>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.accounts, section, form, details[open] { display: grid; align-content: start; gap: var(--space-4); min-width: 0; }
|
||||
.accounts { max-width: 44rem; }
|
||||
h1, h2, p, dl, dd { margin: 0; }
|
||||
h1 { font-size: var(--font-size-title); line-height: var(--line-height-title); }
|
||||
h2 { font-size: var(--font-size-body); line-height: var(--line-height-body); }
|
||||
dl { display: grid; grid-template-columns: auto minmax(0, 1fr); gap: var(--space-2) var(--space-4); }
|
||||
dd { overflow-wrap: anywhere; font-family: var(--font-mono); }
|
||||
.muted { color: var(--text-muted); }
|
||||
.error { color: var(--danger); }
|
||||
input, select { width: 100%; min-width: 0; padding: var(--space-2); border: 1px solid var(--line-strong); border-radius: var(--radius-soft); background: var(--bg); color: var(--text-strong); font: inherit; }
|
||||
button { justify-self: start; min-height: 2.25rem; padding: var(--space-2) var(--space-3); border: 1px solid var(--line-strong); border-radius: var(--radius-soft); background: transparent; color: var(--text-strong); font: inherit; cursor: pointer; }
|
||||
button.primary { background: var(--text-strong); color: var(--bg); }
|
||||
button:hover:not(:disabled) { background: var(--interactive-hover); color: var(--text-strong); }
|
||||
button:disabled { opacity: .55; cursor: not-allowed; }
|
||||
a { color: var(--text-strong); text-decoration: underline; text-underline-offset: .15em; }
|
||||
a:hover, a:active { color: var(--text); }
|
||||
a:focus-visible, button:focus-visible, input:focus-visible, select:focus-visible, summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
</style>
|
||||
@@ -0,0 +1,115 @@
|
||||
// @vitest-environment happy-dom
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/svelte";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import Accounts from "./+page.svelte";
|
||||
import { authUser } from "#lib/workspace/auth/api.test-fixtures.ts";
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
function boundary(
|
||||
mode: "admin" | "anonymous" | "ordinary" | "revoked" | "unknown" | "loading",
|
||||
) {
|
||||
let accesses = 0;
|
||||
const fetcher = vi.fn<typeof fetch>(async (input, init) => {
|
||||
if (input === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
actor: mode === "anonymous"
|
||||
? null
|
||||
: { ...authUser, auth_method: "browser_session" },
|
||||
});
|
||||
}
|
||||
if (input === "/api/hosting/access") {
|
||||
accesses++;
|
||||
if (mode === "loading") return await new Promise<Response>(() => {});
|
||||
return Response.json({
|
||||
is_admin: mode !== "ordinary" && !(mode === "revoked" && accesses > 1),
|
||||
});
|
||||
}
|
||||
if (
|
||||
init?.method === "POST" &&
|
||||
String(input).startsWith("/api/hosting/accounts")
|
||||
) {
|
||||
if (mode === "unknown") throw new Error("private diagnostic");
|
||||
return Response.json({
|
||||
user: authUser,
|
||||
code: "synthetic-output-only",
|
||||
expires_at: new Date(Date.now() + 900000).toISOString(),
|
||||
});
|
||||
}
|
||||
throw new Error("Unexpected endpoint");
|
||||
});
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
return fetcher;
|
||||
}
|
||||
async function issue() {
|
||||
await fireEvent.input(await screen.findByLabelText("Account ID (handle)"), {
|
||||
target: { value: " ALICE " },
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Create account and issue code" }),
|
||||
);
|
||||
}
|
||||
for (const mode of ["anonymous", "ordinary", "loading"] as const) {
|
||||
test(`${mode} hides account mutations before current administrator access is confirmed`, async () => {
|
||||
const fetcher = boundary(mode);
|
||||
render(Accounts);
|
||||
await screen.findByText(
|
||||
mode === "anonymous"
|
||||
? /Sign in with a hosting administrator/
|
||||
: mode === "ordinary"
|
||||
? "Hosting administrator access is required."
|
||||
: "Checking hosting access…",
|
||||
);
|
||||
expect(screen.queryByLabelText("Account ID (handle)")).toBeNull();
|
||||
expect(fetcher.mock.calls.some(([, init]) => init?.method === "POST")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
}
|
||||
test("creation shows issuance only in page memory and hide clears the code", async () => {
|
||||
const fetcher = boundary("admin");
|
||||
render(Accounts);
|
||||
await issue();
|
||||
await screen.findByRole("heading", { name: "Activation code issued" });
|
||||
const post = fetcher.mock.calls.find(([, init]) => init?.method === "POST")!;
|
||||
expect(JSON.parse(String(post[1]?.body))).toEqual({ handle: "alice" });
|
||||
expect(post[0]).toBe("/api/hosting/accounts");
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Hide code" }));
|
||||
expect(screen.queryByText("synthetic-output-only")).toBeNull();
|
||||
expect(screen.getByLabelText("Account ID (handle)")).toBeTruthy();
|
||||
// 実際のselect操作と再発行はproduction-shell browser suiteが確認する。
|
||||
});
|
||||
test("access revoked after page load prevents issuance", async () => {
|
||||
const fetcher = boundary("revoked");
|
||||
render(Accounts);
|
||||
await issue();
|
||||
await screen.findByText("Hosting administrator access is required.");
|
||||
expect(fetcher.mock.calls.some(([, init]) => init?.method === "POST")).toBe(
|
||||
false,
|
||||
);
|
||||
});
|
||||
test("lost issuance response never retries or echoes private diagnostics", async () => {
|
||||
const fetcher = boundary("unknown");
|
||||
render(Accounts);
|
||||
await issue();
|
||||
await waitFor(() =>
|
||||
expect(screen.getByRole("alert").textContent).toContain(
|
||||
"could not be confirmed",
|
||||
)
|
||||
);
|
||||
expect(screen.getByRole("alert").textContent).not.toContain(
|
||||
"private diagnostic",
|
||||
);
|
||||
expect(screen.queryByRole("heading", { name: "Activation code issued" }))
|
||||
.toBeNull();
|
||||
expect(
|
||||
fetcher.mock.calls.filter(([, init]) => init?.method === "POST").length,
|
||||
).toBe(1);
|
||||
});
|
||||
@@ -0,0 +1,176 @@
|
||||
<script lang="ts">
|
||||
import { onMount, tick } from 'svelte';
|
||||
import type { HostingSettings } from '#lib/generated/auth-api.ts';
|
||||
import { loadWhoami } from '#lib/workspace/auth/api.ts';
|
||||
import {
|
||||
HostingRequestError,
|
||||
loadHostingAccess,
|
||||
loadHostingSettings,
|
||||
saveHostingSettings,
|
||||
SESSION_CHANGED_EVENT,
|
||||
} from '#lib/workspace/auth/hosting.ts';
|
||||
|
||||
let viewState = $state<'loading' | 'anonymous' | 'denied' | 'error' | 'ready'>('loading');
|
||||
let settings = $state<HostingSettings | null>(null);
|
||||
let draft = $state(false);
|
||||
let editing = $state(false);
|
||||
let saving = $state(false);
|
||||
let saveBlocked = $state(false);
|
||||
let error = $state('');
|
||||
let status = $state('');
|
||||
let request = 0;
|
||||
let editButton = $state<HTMLButtonElement>();
|
||||
let checkbox = $state<HTMLInputElement>();
|
||||
|
||||
function permissionFailure(err: unknown): boolean {
|
||||
if (!(err instanceof HostingRequestError)) return false;
|
||||
if (err.status !== 401 && err.status !== 403) return false;
|
||||
settings = null;
|
||||
editing = false;
|
||||
viewState = err.status === 401 ? 'anonymous' : 'denied';
|
||||
return true;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
const current = ++request;
|
||||
viewState = 'loading';
|
||||
settings = null;
|
||||
editing = false;
|
||||
error = '';
|
||||
status = '';
|
||||
saveBlocked = false;
|
||||
try {
|
||||
const whoami = await loadWhoami();
|
||||
if (current !== request) return;
|
||||
if (!whoami.actor) { viewState = 'anonymous'; return; }
|
||||
const access = await loadHostingAccess();
|
||||
if (current !== request) return;
|
||||
if (!access.is_admin) { viewState = 'denied'; return; }
|
||||
const loaded = await loadHostingSettings();
|
||||
if (current !== request) return;
|
||||
settings = loaded;
|
||||
draft = loaded.self_registration_enabled;
|
||||
viewState = 'ready';
|
||||
} catch (err) {
|
||||
if (current !== request) return;
|
||||
if (!permissionFailure(err)) {
|
||||
viewState = 'error';
|
||||
error = 'Hosting settings could not be loaded.';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function edit() {
|
||||
if (!settings) return;
|
||||
draft = settings.self_registration_enabled;
|
||||
editing = true;
|
||||
status = '';
|
||||
await tick();
|
||||
checkbox?.focus();
|
||||
}
|
||||
|
||||
async function cancel() {
|
||||
editing = false;
|
||||
await tick();
|
||||
editButton?.focus();
|
||||
}
|
||||
|
||||
async function save() {
|
||||
if (saving || saveBlocked || viewState !== 'ready' || !settings) return;
|
||||
const current = request;
|
||||
saving = true;
|
||||
error = '';
|
||||
status = '';
|
||||
try {
|
||||
const saved = await saveHostingSettings({ self_registration_enabled: draft });
|
||||
if (current !== request) return;
|
||||
settings = saved;
|
||||
draft = saved.self_registration_enabled;
|
||||
editing = false;
|
||||
status = 'Hosting settings saved.';
|
||||
await tick();
|
||||
editButton?.focus();
|
||||
} catch (err) {
|
||||
if (current !== request) return;
|
||||
if (!permissionFailure(err)) {
|
||||
// 応答喪失時に未保存と断定しない。再読込後に次の保存を判断する。
|
||||
saveBlocked = true;
|
||||
error = 'Save could not be confirmed. Reload settings before making another change.';
|
||||
}
|
||||
} finally {
|
||||
saving = false;
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
void load();
|
||||
const refresh = () => { void load(); };
|
||||
window.addEventListener(SESSION_CHANGED_EVENT, refresh);
|
||||
return () => {
|
||||
++request;
|
||||
window.removeEventListener(SESSION_CHANGED_EVENT, refresh);
|
||||
};
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Hosting settings · Yoi</title></svelte:head>
|
||||
|
||||
<div class="hosting-settings">
|
||||
<h1>Hosting settings</h1>
|
||||
{#if viewState === 'loading'}
|
||||
<p role="status">Loading hosting settings…</p>
|
||||
{:else if viewState === 'anonymous'}
|
||||
<p>Sign in with a hosting administrator account to view these settings.</p>
|
||||
<a href="/account">Open Account</a>
|
||||
{:else if viewState === 'denied'}
|
||||
<p>Hosting administrator access is required.</p>
|
||||
{:else if viewState === 'error'}
|
||||
<p class="error" role="alert">{error}</p>
|
||||
<button type="button" onclick={load}>Retry loading settings</button>
|
||||
{:else if settings}
|
||||
<a href="/hosting/accounts">Create accounts or reissue activation codes</a>
|
||||
<section aria-labelledby="registration-heading">
|
||||
<h2 id="registration-heading">Self-registration</h2>
|
||||
<p class="muted">Applies to this hosting, across all Workspaces.</p>
|
||||
<p>Last loaded policy: <strong>{settings.self_registration_enabled ? 'Enabled' : 'Disabled'}</strong></p>
|
||||
{#if editing}
|
||||
<form onsubmit={(event) => { event.preventDefault(); void save(); }}>
|
||||
<label class="checkbox-row">
|
||||
<input bind:this={checkbox} type="checkbox" bind:checked={draft} disabled={saving || saveBlocked} aria-describedby="registration-impact" />
|
||||
Allow new account registration
|
||||
</label>
|
||||
<p id="registration-impact" class="muted">Disabling registration does not prevent existing users from logging in or adding a passkey to their own account.</p>
|
||||
<div class="actions">
|
||||
<button class="primary" type="submit" disabled={saving || saveBlocked || draft === settings.self_registration_enabled}>{saving ? 'Saving…' : 'Save settings'}</button>
|
||||
<button type="button" disabled={saving} onclick={cancel}>Cancel</button>
|
||||
</div>
|
||||
</form>
|
||||
{:else}
|
||||
<button bind:this={editButton} type="button" disabled={saveBlocked} onclick={edit}>Edit registration policy</button>
|
||||
{/if}
|
||||
{#if error}
|
||||
<p class="error" role="alert">{error}</p>
|
||||
<button type="button" onclick={load}>Reload settings</button>
|
||||
{/if}
|
||||
{#if status}<p role="status">{status}</p>{/if}
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.hosting-settings, section, form { display: grid; align-content: start; gap: var(--space-4); min-width: 0; }
|
||||
.hosting-settings { max-width: 48rem; }
|
||||
h1, h2, p { margin: 0; }
|
||||
h1 { font-size: var(--font-size-title); line-height: var(--line-height-title); }
|
||||
h2 { font-size: var(--font-size-body); line-height: var(--line-height-body); }
|
||||
.muted { color: var(--text-muted); }
|
||||
.error { color: var(--danger); }
|
||||
.actions { display: flex; flex-wrap: wrap; gap: var(--space-2); }
|
||||
.checkbox-row { display: flex; align-items: center; gap: var(--space-2); }
|
||||
input { flex: 0 0 auto; width: 1rem; height: 1rem; }
|
||||
button { justify-self: start; min-height: 2.25rem; padding: var(--space-2) var(--space-3); border: 1px solid var(--line-strong); border-radius: var(--radius-soft); background: transparent; color: var(--text-strong); font: inherit; cursor: pointer; }
|
||||
button.primary { background: var(--text-strong); color: var(--bg); }
|
||||
button:hover:not(:disabled) { background: var(--interactive-hover); color: var(--text-strong); }
|
||||
button:disabled { opacity: .55; cursor: not-allowed; }
|
||||
button:focus-visible, input:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
</style>
|
||||
@@ -0,0 +1,168 @@
|
||||
// @vitest-environment happy-dom
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/svelte";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import Settings from "./+page.svelte";
|
||||
import { authUser } from "#lib/workspace/auth/api.test-fixtures.ts";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
function boundary(
|
||||
persona: "anonymous" | "ordinary" | "admin",
|
||||
enabled = true,
|
||||
saveStatus = 200,
|
||||
) {
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(
|
||||
async (input, init) => {
|
||||
const path = String(input);
|
||||
if (path === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
actor: persona === "anonymous"
|
||||
? null
|
||||
: { ...authUser, auth_method: "browser_session" },
|
||||
});
|
||||
}
|
||||
if (path === "/api/hosting/access") {
|
||||
return Response.json({ is_admin: persona === "admin" });
|
||||
}
|
||||
if (path === "/api/hosting/settings") {
|
||||
if (persona !== "admin") return new Response(null, { status: 403 });
|
||||
if (init?.method === "PUT") {
|
||||
if (saveStatus !== 200) {
|
||||
return new Response(null, { status: saveStatus });
|
||||
}
|
||||
enabled = JSON.parse(String(init.body)).self_registration_enabled;
|
||||
}
|
||||
return Response.json({ self_registration_enabled: enabled });
|
||||
}
|
||||
throw new Error(`Unexpected API ${path}`);
|
||||
},
|
||||
);
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
return fetcher;
|
||||
}
|
||||
|
||||
for (const persona of ["anonymous", "ordinary"] as const) {
|
||||
test(`${persona} direct hosting screen contains no settings controls or privileged requests`, async () => {
|
||||
const fetcher = boundary(persona);
|
||||
render(Settings);
|
||||
await screen.findByText(
|
||||
persona === "anonymous"
|
||||
? "Sign in with a hosting administrator account to view these settings."
|
||||
: "Hosting administrator access is required.",
|
||||
);
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
expect(screen.queryByRole("checkbox")).toBeNull();
|
||||
expect(
|
||||
fetcher.mock.calls.some(([path]) => path === "/api/hosting/settings"),
|
||||
).toBe(false);
|
||||
});
|
||||
}
|
||||
|
||||
for (const enabled of [true, false]) {
|
||||
test(`admin reads policy ${enabled}, edits with keyboard focus, cancels and saves confirmed value`, async () => {
|
||||
const fetcher = boundary("admin", enabled);
|
||||
render(Settings);
|
||||
const edit = await screen.findByRole("button", {
|
||||
name: "Edit registration policy",
|
||||
});
|
||||
expect(screen.getByText(enabled ? "Enabled" : "Disabled")).toBeTruthy();
|
||||
expect(screen.queryByRole("checkbox")).toBeNull();
|
||||
await fireEvent.click(edit);
|
||||
let checkbox = screen.getByRole("checkbox") as HTMLInputElement;
|
||||
await waitFor(() => expect(document.activeElement).toBe(checkbox));
|
||||
expect(checkbox.checked).toBe(enabled);
|
||||
expect(
|
||||
(screen.getByRole("button", {
|
||||
name: "Save settings",
|
||||
}) as HTMLButtonElement).disabled,
|
||||
).toBe(true);
|
||||
await fireEvent.click(checkbox);
|
||||
await fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
|
||||
await waitFor(() =>
|
||||
expect(document.activeElement).toBe(
|
||||
screen.getByRole("button", { name: "Edit registration policy" }),
|
||||
)
|
||||
);
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Edit registration policy" }),
|
||||
);
|
||||
checkbox = screen.getByRole("checkbox") as HTMLInputElement;
|
||||
expect(checkbox.checked).toBe(enabled);
|
||||
await fireEvent.click(checkbox);
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Save settings" }),
|
||||
);
|
||||
await screen.findByText("Hosting settings saved.");
|
||||
expect(screen.getByText(enabled ? "Disabled" : "Enabled")).toBeTruthy();
|
||||
const writes = fetcher.mock.calls.filter(([, init]) =>
|
||||
init?.method === "PUT"
|
||||
);
|
||||
expect(writes).toHaveLength(1);
|
||||
expect(JSON.parse(String(writes[0][1]?.body))).toEqual({
|
||||
self_registration_enabled: !enabled,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
for (const status of [401, 403, 503]) {
|
||||
test(`save rejection ${status} never claims success or automatically retries`, async () => {
|
||||
const fetcher = boundary("admin", true, status);
|
||||
render(Settings);
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Edit registration policy" }),
|
||||
);
|
||||
await fireEvent.click(screen.getByRole("checkbox"));
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Save settings" }),
|
||||
);
|
||||
if (status === 503) {
|
||||
await screen.findByText(
|
||||
"Save could not be confirmed. Reload settings before making another change.",
|
||||
);
|
||||
expect(screen.getByText("Enabled")).toBeTruthy();
|
||||
expect(
|
||||
(screen.getByRole("button", {
|
||||
name: "Save settings",
|
||||
}) as HTMLButtonElement).disabled,
|
||||
).toBe(true);
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Reload settings" }),
|
||||
);
|
||||
await screen.findByRole("button", { name: "Edit registration policy" });
|
||||
} else {
|
||||
await screen.findByText(
|
||||
status === 401
|
||||
? "Sign in with a hosting administrator account to view these settings."
|
||||
: "Hosting administrator access is required.",
|
||||
);
|
||||
expect(screen.queryByRole("checkbox")).toBeNull();
|
||||
expect(screen.queryByRole("button")).toBeNull();
|
||||
}
|
||||
expect(screen.queryByText("Hosting settings saved.")).toBeNull();
|
||||
expect(fetcher.mock.calls.filter(([, init]) => init?.method === "PUT"))
|
||||
.toHaveLength(1);
|
||||
});
|
||||
}
|
||||
|
||||
test("unconfirmed access and settings failures show no settings controls and offer retry", async () => {
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async () => new Response(null, { status: 503 })),
|
||||
);
|
||||
render(Settings);
|
||||
await screen.findByText("Hosting settings could not be loaded.");
|
||||
expect(screen.queryByRole("checkbox")).toBeNull();
|
||||
expect(screen.queryByRole("button", { name: "Edit registration policy" }))
|
||||
.toBeNull();
|
||||
expect(screen.getByRole("button", { name: "Retry loading settings" }))
|
||||
.toBeTruthy();
|
||||
});
|
||||
@@ -0,0 +1,116 @@
|
||||
<script lang="ts">
|
||||
import { onDestroy } from 'svelte';
|
||||
import { loadWhoami, loginWithPasskey } from '#lib/workspace/auth/api.ts';
|
||||
import { enrollHostingPasskey } from '#lib/workspace/auth/hosting-enrollment.ts';
|
||||
import { loadHostingAccess, notifySessionChanged } from '#lib/workspace/auth/hosting.ts';
|
||||
import type { AuthUser } from '#lib/workspace/auth/model.ts';
|
||||
|
||||
let token = $state('');
|
||||
let user = $state<AuthUser | null>(null);
|
||||
let busy = $state(false);
|
||||
let verifiedLogin = $state(false);
|
||||
let error = $state('');
|
||||
const controller = new AbortController();
|
||||
let active = true;
|
||||
|
||||
// 初期Bearerでの鍵追加はcookieを使わない。明示Passkey loginのsessionを検証する。
|
||||
async function verifySession(expected: AuthUser): Promise<boolean> {
|
||||
const { actor } = await loadWhoami();
|
||||
if (!active || actor?.user_id !== expected.user_id || actor.account_id !== expected.account_id || actor.auth_method !== 'browser_session') return false;
|
||||
const access = await loadHostingAccess();
|
||||
return active && access.is_admin;
|
||||
}
|
||||
|
||||
async function enroll() {
|
||||
if (busy || user || !token.trim()) return;
|
||||
busy = true;
|
||||
error = '';
|
||||
const pending = enrollHostingPasskey(token.trim(), controller.signal);
|
||||
// 非同期操作に渡した後、form側にはsecretを残さない。
|
||||
token = '';
|
||||
try {
|
||||
const enrolled = await pending;
|
||||
if (!active) return;
|
||||
user = enrolled;
|
||||
} catch {
|
||||
if (active) error = 'Passkey enrollment could not be confirmed. The token was cleared. If you completed the passkey prompt, try signing in from Account before enrolling again. Otherwise check the token and its expiry with the host operator.';
|
||||
} finally {
|
||||
token = '';
|
||||
if (active) busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function login() {
|
||||
if (busy || !user) return;
|
||||
const expected = user;
|
||||
busy = true;
|
||||
error = '';
|
||||
try {
|
||||
const signedIn = await loginWithPasskey(expected.handle);
|
||||
if (!active) return;
|
||||
if (signedIn.user_id !== expected.user_id || signedIn.account_id !== expected.account_id || !await verifySession(expected)) {
|
||||
throw new Error('Login or hosting access not verified');
|
||||
}
|
||||
if (!active) return;
|
||||
verifiedLogin = true;
|
||||
notifySessionChanged();
|
||||
} catch {
|
||||
if (active) error = 'Passkey login or hosting administrator access could not be verified. Try signing in again, or ask the host operator to check this account.';
|
||||
} finally {
|
||||
if (active) busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
active = false;
|
||||
token = '';
|
||||
controller.abort();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head><title>Hosting setup · Yoi</title></svelte:head>
|
||||
|
||||
<div class="hosting-setup">
|
||||
<h1>Hosting setup</h1>
|
||||
{#if !user}
|
||||
<p>Use a host-issued setup token to add the first passkey to the administrator account selected by the host. Opening this page does not create an account or grant administrator access.</p>
|
||||
<details>
|
||||
<summary>Get a token on the trusted host</summary>
|
||||
<p>The host operator runs this command against the Server database, using a new handle:</p>
|
||||
<pre><code>yoi-server hosting-admin --database PATH --handle NEW --create-user</code></pre>
|
||||
<p>Use the access_token printed once by that command within 15 minutes. Keep it private; do not put it in a URL, screenshot, shared terminal log, or support message. This is not public registration or an activation code.</p>
|
||||
</details>
|
||||
<form onsubmit={(event) => { event.preventDefault(); void enroll(); }} autocomplete="off">
|
||||
<label for="host-setup-token">Host setup token</label>
|
||||
<input id="host-setup-token" type="password" bind:value={token} autocomplete="off" spellcheck="false" autocapitalize="off" maxlength="4096" disabled={busy} required aria-describedby="setup-token-help" />
|
||||
<p id="setup-token-help" class="muted">Only enter a token from your trusted host operator on this Server’s trusted HTTPS origin (or localhost). It is kept only in this page’s memory and cleared after an attempt or when leaving.</p>
|
||||
<button class="primary" type="submit" disabled={busy || !token.trim()}>{busy ? 'Adding passkey…' : 'Add administrator passkey'}</button>
|
||||
</form>
|
||||
<a href="/account">Already have a passkey? Open Account</a>
|
||||
{:else if !verifiedLogin}
|
||||
<p role="status">Passkey added for {user.handle}. Enrollment is not a passkey login.</p>
|
||||
<p>Sign in with the new passkey to verify it before opening hosting settings.</p>
|
||||
<button class="primary" type="button" disabled={busy} onclick={login}>{busy ? 'Signing in…' : 'Sign in with new passkey'}</button>
|
||||
{:else}
|
||||
<p role="status">Passkey login and hosting administrator access verified.</p>
|
||||
<a href="/hosting/settings">Open hosting settings</a>
|
||||
{/if}
|
||||
{#if error}<p class="error" role="alert">{error}</p>{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.hosting-setup, form, details[open] { display: grid; align-content: start; gap: var(--space-4); min-width: 0; }
|
||||
.hosting-setup { max-width: 42rem; }
|
||||
h1, p, pre { margin: 0; }
|
||||
h1 { font-size: var(--font-size-title); line-height: var(--line-height-title); }
|
||||
.muted { color: var(--text-muted); }
|
||||
.error { color: var(--danger); }
|
||||
pre { white-space: pre-wrap; overflow-wrap: anywhere; font-family: var(--font-mono); }
|
||||
summary { cursor: pointer; }
|
||||
input { width: 100%; min-width: 0; padding: var(--space-2); border: 1px solid var(--line-strong); border-radius: var(--radius-soft); background: var(--bg); color: var(--text-strong); font: inherit; }
|
||||
button { justify-self: start; min-height: 2.25rem; padding: var(--space-2) var(--space-3); border: 1px solid var(--line-strong); border-radius: var(--radius-soft); font: inherit; cursor: pointer; }
|
||||
button.primary { background: var(--text-strong); color: var(--bg); }
|
||||
button:hover:not(:disabled) { background: var(--interactive-hover); color: var(--text-strong); }
|
||||
button:disabled { opacity: .55; cursor: not-allowed; }
|
||||
button:focus-visible, input:focus-visible, summary:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
|
||||
</style>
|
||||
@@ -0,0 +1,263 @@
|
||||
// @vitest-environment happy-dom
|
||||
import {
|
||||
cleanup,
|
||||
fireEvent,
|
||||
render,
|
||||
screen,
|
||||
waitFor,
|
||||
} from "@testing-library/svelte";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import Setup from "./+page.svelte";
|
||||
import {
|
||||
authUser,
|
||||
credentialFixture,
|
||||
loginOptions,
|
||||
registrationOptions,
|
||||
} from "#lib/workspace/auth/api.test-fixtures.ts";
|
||||
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
// UI→本番adapter/serializerを接続。fetchとbrowser credentialだけ代替する。
|
||||
// Backend認証・権限の拒否や署名を検証する試験ではない。
|
||||
function boundary(
|
||||
{ status = 200, admin = true, actor = true, failLogin = false } = {},
|
||||
) {
|
||||
vi.stubGlobal("isSecureContext", true);
|
||||
const create = vi.fn(async () => credentialFixture());
|
||||
const get = vi.fn(async () => credentialFixture(true));
|
||||
vi.stubGlobal("navigator", { credentials: { create, get } });
|
||||
const fetcher = vi.fn<typeof fetch>().mockImplementation(async (input) => {
|
||||
const path = String(input);
|
||||
if (path === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
actor: actor ? { ...authUser, auth_method: "browser_session" } : null,
|
||||
});
|
||||
}
|
||||
if (path === "/api/hosting/access") {
|
||||
return Response.json({ is_admin: admin });
|
||||
}
|
||||
if (path === "/api/auth/passkeys/addition/options") {
|
||||
return status === 200
|
||||
? Response.json(registrationOptions)
|
||||
: new Response("host-test-token", { status });
|
||||
}
|
||||
if (path === "/api/auth/passkeys/addition/complete") {
|
||||
return Response.json({ user: authUser });
|
||||
}
|
||||
if (path === "/api/auth/passkeys/login/options") {
|
||||
return Response.json(loginOptions);
|
||||
}
|
||||
if (path === "/api/auth/passkeys/login/complete") {
|
||||
return failLogin
|
||||
? new Response(null, { status: 401 })
|
||||
: Response.json({ user: authUser });
|
||||
}
|
||||
throw new Error(`Unexpected API: ${path}`);
|
||||
});
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
return { fetcher, create, get };
|
||||
}
|
||||
async function enroll() {
|
||||
await fireEvent.input(screen.getByLabelText("Host setup token"), {
|
||||
target: { value: "host-test-token" },
|
||||
});
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Add administrator passkey" }),
|
||||
);
|
||||
}
|
||||
|
||||
test("public shell does not request or grant anything without explicit host token input", () => {
|
||||
const { fetcher, create } = boundary();
|
||||
render(Setup);
|
||||
const input = screen.getByLabelText("Host setup token") as HTMLInputElement;
|
||||
expect(input.type).toBe("password");
|
||||
expect(input.autocomplete).toBe("off");
|
||||
expect(input.getAttribute("name")).toBeNull();
|
||||
expect(
|
||||
(screen.getByRole("button", {
|
||||
name: "Add administrator passkey",
|
||||
}) as HTMLButtonElement).disabled,
|
||||
).toBe(true);
|
||||
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
|
||||
.toBeNull();
|
||||
expect(fetcher).not.toHaveBeenCalled();
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test("enrollment clears token and omits cookies, then verifies an actual passkey login before settings", async () => {
|
||||
const { fetcher, create, get } = boundary();
|
||||
const localWrite = vi.spyOn(window.localStorage, "setItem");
|
||||
const sessionWrite = vi.spyOn(window.sessionStorage, "setItem");
|
||||
render(Setup);
|
||||
await enroll();
|
||||
await screen.findByRole("button", { name: "Sign in with new passkey" });
|
||||
expect(fetcher.mock.calls.some(([path]) => path === "/api/hosting/access"))
|
||||
.toBe(false);
|
||||
expect(create).toHaveBeenCalledTimes(1);
|
||||
expect(get).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
|
||||
.toBeNull();
|
||||
expect(document.body.textContent).not.toContain("host-test-token");
|
||||
expect(localWrite).not.toHaveBeenCalled();
|
||||
expect(sessionWrite).not.toHaveBeenCalled();
|
||||
const requests = fetcher.mock.calls;
|
||||
for (const [path, init] of requests) {
|
||||
expect(String(path)).not.toContain("host-test-token");
|
||||
expect(String(init?.body)).not.toContain("host-test-token");
|
||||
expect(new Headers(init?.headers).get("authorization")).toBe(
|
||||
String(path).includes("/addition/") ? "Bearer host-test-token" : null,
|
||||
);
|
||||
}
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Sign in with new passkey" }),
|
||||
);
|
||||
const settings = await screen.findByRole("link", {
|
||||
name: "Open hosting settings",
|
||||
});
|
||||
expect(settings.getAttribute("href")).toBe("/hosting/settings");
|
||||
expect(get).toHaveBeenCalledTimes(1);
|
||||
expect(fetcher.mock.calls.filter(([path]) => path === "/api/auth/whoami"))
|
||||
.toHaveLength(1);
|
||||
expect(fetcher.mock.calls.filter(([path]) => path === "/api/hosting/access"))
|
||||
.toHaveLength(1);
|
||||
expect(
|
||||
fetcher.mock.calls.some(([path]) => String(path).includes("registration")),
|
||||
).toBe(false);
|
||||
});
|
||||
|
||||
for (const status of [401, 403, 503]) {
|
||||
test(`token rejection ${status} clears input and never falls back to registration`, async () => {
|
||||
const { fetcher, create } = boundary({ status });
|
||||
const log = vi.spyOn(console, "error");
|
||||
render(Setup);
|
||||
await enroll();
|
||||
await screen.findByRole("alert");
|
||||
expect(
|
||||
(screen.getByLabelText("Host setup token") as HTMLInputElement).value,
|
||||
).toBe("");
|
||||
expect(screen.getByRole("alert").textContent).not.toContain(
|
||||
"host-test-token",
|
||||
);
|
||||
expect(log).not.toHaveBeenCalled();
|
||||
expect(fetcher).toHaveBeenCalledTimes(1);
|
||||
expect(create).not.toHaveBeenCalled();
|
||||
expect(screen.queryByRole("button", { name: "Sign in with new passkey" }))
|
||||
.toBeNull();
|
||||
});
|
||||
}
|
||||
|
||||
for (
|
||||
const context of [{ admin: false }, { actor: false }, { failLogin: true }]
|
||||
) {
|
||||
test(`unverified login or hosting access never exposes settings: ${JSON.stringify(context)}`, async () => {
|
||||
boundary(context);
|
||||
render(Setup);
|
||||
await enroll();
|
||||
await fireEvent.click(
|
||||
await screen.findByRole("button", { name: "Sign in with new passkey" }),
|
||||
);
|
||||
await screen.findByRole("alert");
|
||||
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
|
||||
.toBeNull();
|
||||
});
|
||||
}
|
||||
|
||||
test("a different cookie actor cannot be accepted as the enrolled administrator", async () => {
|
||||
const { fetcher } = boundary();
|
||||
const original = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation(async (input, init) =>
|
||||
String(input) === "/api/auth/whoami"
|
||||
? Response.json({
|
||||
actor: {
|
||||
...authUser,
|
||||
user_id: "other-user",
|
||||
auth_method: "browser_session",
|
||||
},
|
||||
})
|
||||
: original(input, init)
|
||||
);
|
||||
render(Setup);
|
||||
await enroll();
|
||||
await screen.findByRole("button", { name: "Sign in with new passkey" });
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Sign in with new passkey" }),
|
||||
);
|
||||
await screen.findByRole("alert");
|
||||
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
|
||||
.toBeNull();
|
||||
expect(fetcher.mock.calls.some(([path]) => path === "/api/hosting/access"))
|
||||
.toBe(false);
|
||||
});
|
||||
|
||||
test("leaving during the passkey prompt aborts enrollment and prevents later completion", async () => {
|
||||
const { create, fetcher } = boundary();
|
||||
let resolve!: (credential: Credential) => void;
|
||||
create.mockImplementation(() =>
|
||||
new Promise<Credential>((done) => {
|
||||
resolve = done;
|
||||
})
|
||||
);
|
||||
const { unmount } = render(Setup);
|
||||
await enroll();
|
||||
await waitFor(() => expect(create).toHaveBeenCalledTimes(1));
|
||||
expect((screen.getByLabelText("Host setup token") as HTMLInputElement).value)
|
||||
.toBe("");
|
||||
const signal =
|
||||
(create.mock.calls[0] as unknown as [CredentialCreationOptions])[0].signal!;
|
||||
unmount();
|
||||
expect(signal.aborted).toBe(true);
|
||||
resolve(credentialFixture());
|
||||
await waitFor(() => expect(fetcher).toHaveBeenCalledTimes(1));
|
||||
const { unmount: unmountAgain } = render(Setup);
|
||||
expect((screen.getByLabelText("Host setup token") as HTMLInputElement).value)
|
||||
.toBe("");
|
||||
unmountAgain();
|
||||
});
|
||||
|
||||
test("completion response loss does not claim success and points to login before another enrollment", async () => {
|
||||
const { fetcher } = boundary();
|
||||
const original = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation(async (input, init) => {
|
||||
if (String(input) === "/api/auth/passkeys/addition/complete") {
|
||||
throw new Error("host-test-token");
|
||||
}
|
||||
return original(input, init);
|
||||
});
|
||||
render(Setup);
|
||||
await enroll();
|
||||
await screen.findByRole("alert");
|
||||
expect(screen.getByRole("alert").textContent).toContain(
|
||||
"try signing in from Account",
|
||||
);
|
||||
expect(screen.getByRole("alert").textContent).not.toContain(
|
||||
"host-test-token",
|
||||
);
|
||||
expect((screen.getByLabelText("Host setup token") as HTMLInputElement).value)
|
||||
.toBe("");
|
||||
expect(fetcher).toHaveBeenCalledTimes(2);
|
||||
expect(screen.queryByRole("button", { name: "Sign in with new passkey" }))
|
||||
.toBeNull();
|
||||
});
|
||||
|
||||
test("API token identity is not mistaken for a verified browser session", async () => {
|
||||
const { fetcher } = boundary();
|
||||
const original = fetcher.getMockImplementation()!;
|
||||
fetcher.mockImplementation(async (input, init) =>
|
||||
String(input) === "/api/auth/whoami"
|
||||
? Response.json({ actor: { ...authUser, auth_method: "api_token" } })
|
||||
: original(input, init)
|
||||
);
|
||||
render(Setup);
|
||||
await enroll();
|
||||
await screen.findByRole("button", { name: "Sign in with new passkey" });
|
||||
await fireEvent.click(
|
||||
screen.getByRole("button", { name: "Sign in with new passkey" }),
|
||||
);
|
||||
await screen.findByRole("alert");
|
||||
expect(screen.queryByRole("link", { name: "Open hosting settings" }))
|
||||
.toBeNull();
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
<script lang="ts">
|
||||
import AuthEntry from '#lib/workspace/auth/AuthEntry.svelte';
|
||||
</script>
|
||||
|
||||
<AuthEntry mode="login" />
|
||||
@@ -1,119 +1,55 @@
|
||||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { page } from '$app/state';
|
||||
import {
|
||||
approveDeviceLogin,
|
||||
loadWhoami,
|
||||
loginWithPasskey,
|
||||
} from '#lib/workspace/auth/api.ts';
|
||||
import '#lib/workspace/styles/settings.css';
|
||||
import type { RequestActor } from '#lib/workspace/auth/model.ts';
|
||||
import { approveDeviceLogin } from '#lib/workspace/auth/api.ts';
|
||||
import { BrowserAuth } from '#lib/workspace/auth/browser-session.svelte.ts';
|
||||
import { loginUrl } from '#lib/workspace/auth/routes.ts';
|
||||
import '#lib/workspace/auth/auth-page.css';
|
||||
|
||||
let actor = $state<RequestActor | null>(null);
|
||||
let handle = $state('local');
|
||||
const auth = new BrowserAuth();
|
||||
let userCode = $state('');
|
||||
let loading = $state(true);
|
||||
let busy = $state(false);
|
||||
let status = $state('');
|
||||
let error = $state('');
|
||||
|
||||
async function refreshWhoami() {
|
||||
loading = true;
|
||||
error = '';
|
||||
try {
|
||||
const whoami = await loadWhoami();
|
||||
actor = whoami.actor;
|
||||
if (actor?.handle) handle = actor.handle;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function login() {
|
||||
busy = true;
|
||||
status = 'Waiting for passkey login…';
|
||||
error = '';
|
||||
try {
|
||||
await loginWithPasskey(handle.trim());
|
||||
status = 'Logged in. You can approve the CLI login now.';
|
||||
await refreshWhoami();
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
status = '';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
}
|
||||
|
||||
let approved = $state(false);
|
||||
const login = $derived(loginUrl(`/login/device?${new URLSearchParams({ user_code: userCode.trim() })}`));
|
||||
async function approve() {
|
||||
busy = true;
|
||||
status = 'Approving device login…';
|
||||
error = '';
|
||||
try {
|
||||
const result = await approveDeviceLogin(userCode.trim().toUpperCase());
|
||||
status = result.status === 'approved'
|
||||
? 'Device login approved. You can return to the CLI.'
|
||||
: `Device login status: ${result.status}`;
|
||||
} catch (err) {
|
||||
error = err instanceof Error ? err.message : String(err);
|
||||
status = '';
|
||||
} finally {
|
||||
busy = false;
|
||||
}
|
||||
if (auth.busy || !auth.actor || approved || !userCode.trim()) return;
|
||||
const code = userCode.trim().toUpperCase();
|
||||
await auth.run('Approving device login…', 'Device login approved. You can return to the CLI.', async (signal) => {
|
||||
const result = await approveDeviceLogin(code, signal);
|
||||
if (result.status !== 'approved') throw new Error('Device approval not confirmed');
|
||||
approved = true;
|
||||
});
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
userCode = page.url.searchParams.get('user_code') ?? page.url.searchParams.get('code') ?? '';
|
||||
void refreshWhoami();
|
||||
void auth.refresh();
|
||||
return () => auth.dispose();
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Device Login · Yoi Workspace</title>
|
||||
</svelte:head>
|
||||
|
||||
<main class="settings-page">
|
||||
<header class="settings-header">
|
||||
<p class="eyebrow">Device Login</p>
|
||||
<h1>Approve CLI login</h1>
|
||||
<p>Use your browser session to approve a pending CLI/TUI login request.</p>
|
||||
</header>
|
||||
|
||||
<section class="settings-panel">
|
||||
<h2>Browser session</h2>
|
||||
{#if loading}
|
||||
<p class="muted">Loading session…</p>
|
||||
{:else if actor}
|
||||
<p>Logged in as <strong>{actor.display_name}</strong> <span class="muted">@{actor.handle}</span>.</p>
|
||||
{:else}
|
||||
<p class="muted">You need to log in with a passkey before approving a device login.</p>
|
||||
<form class="settings-form" onsubmit={(event) => { event.preventDefault(); void login(); }}>
|
||||
<label>
|
||||
User handle
|
||||
<input bind:value={handle} autocomplete="username webauthn" placeholder="local" />
|
||||
</label>
|
||||
<button type="submit" disabled={busy || !handle.trim()}>Log in with passkey</button>
|
||||
</form>
|
||||
<svelte:head><title>Device Login · Yoi</title></svelte:head>
|
||||
<div class="auth-page" data-sveltekit-preload-data="off">
|
||||
<h1>Approve CLI login</h1>
|
||||
<p>Only approve a code shown by a CLI login you started. Logging in to the browser does not approve the device.</p>
|
||||
{#if auth.session.kind === 'loading'}
|
||||
<p class="muted" role="status">Loading session…</p>
|
||||
{:else if auth.session.kind === 'error'}
|
||||
<p class="error" role="alert">Your session could not be loaded. Login status is unknown.</p>
|
||||
<button type="button" disabled={auth.busy} onclick={() => auth.refresh()}>Retry session</button>
|
||||
{:else if auth.actor}
|
||||
<p>Logged in as <strong>{auth.actor.display_name}</strong> <span class="muted">@{auth.actor.handle}</span>.</p>
|
||||
{:else}
|
||||
<p>You need to log in before approving a device login.</p>
|
||||
<a href={login}>Log in to continue</a>
|
||||
{/if}
|
||||
<form onsubmit={(event) => { event.preventDefault(); void approve(); }}>
|
||||
<label>User code
|
||||
<input bind:value={userCode} required maxlength="64" placeholder="ABCD-EFGH" autocapitalize="characters" disabled={auth.busy || approved} />
|
||||
</label>
|
||||
{#if auth.actor && !approved}
|
||||
<button type="submit" disabled={auth.busy || !userCode.trim()}>Approve device login</button>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
<section class="settings-panel">
|
||||
<h2>Approval code</h2>
|
||||
<form class="settings-form" onsubmit={(event) => { event.preventDefault(); void approve(); }}>
|
||||
<label>
|
||||
User code
|
||||
<input bind:value={userCode} placeholder="ABCD-EFGH" autocapitalize="characters" />
|
||||
</label>
|
||||
<button type="submit" disabled={busy || !actor || !userCode.trim()}>Approve device login</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
{#if status}
|
||||
<p class="status-message">{status}</p>
|
||||
{/if}
|
||||
{#if error}
|
||||
<p class="error-message">{error}</p>
|
||||
{/if}
|
||||
</main>
|
||||
</form>
|
||||
{#if auth.status}<p role="status">{auth.status}</p>{/if}
|
||||
{#if auth.error}<p class="error" role="alert">{auth.error}</p>{/if}
|
||||
<a href="/account">Open Account</a>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
<script lang="ts">
|
||||
import AuthEntry from '#lib/workspace/auth/AuthEntry.svelte';
|
||||
</script>
|
||||
|
||||
<AuthEntry mode="register" />
|
||||
@@ -4,10 +4,12 @@ import { cleanup, fireEvent, render, screen } from "@testing-library/svelte";
|
||||
import { afterEach, beforeEach, expect, test, vi } from "vitest";
|
||||
import { createRawSnippet, tick } from "svelte";
|
||||
import { logout } from "#lib/workspace/auth/api.ts";
|
||||
import { clearWorkspaceAlerts, pushWorkspaceAlert } from '#lib/workspace/alerts/store.ts';
|
||||
import { page } from '$app/state';
|
||||
import { settle } from '#lib/workspace/navigation/deferred.ts';
|
||||
import Layout from "./+layout.svelte";
|
||||
|
||||
vi.mock("$app/state", () => ({ page: { url: new URL("https://example.test/") } }));
|
||||
vi.mock("$app/state", () => ({ page: { url: new URL("https://example.test/w/example") } }));
|
||||
const storageKey = "yoi.sidebar.mode.v1";
|
||||
const legacyKey = "yoi.sidebar.folded.v1";
|
||||
const props = {
|
||||
@@ -22,7 +24,16 @@ const enter = () => fireEvent.pointerEnter(hoverRegion(), { pointerType: "mouse"
|
||||
const leave = () => fireEvent.pointerLeave(hoverRegion(), { pointerType: "mouse" });
|
||||
const delay = () => vi.advanceTimersByTimeAsync(200);
|
||||
|
||||
beforeEach(() => { localStorage.clear(); vi.useFakeTimers(); });
|
||||
beforeEach(() => {
|
||||
localStorage.clear();
|
||||
clearWorkspaceAlerts();
|
||||
vi.stubGlobal('fetch', vi.fn(async (input) => {
|
||||
if (String(input) === '/api/auth/whoami') return Response.json({ actor: null });
|
||||
throw new Error(`Unexpected request ${input}`);
|
||||
}));
|
||||
page.url = new URL('https://example.test/w/example');
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
afterEach(() => {
|
||||
cleanup();
|
||||
vi.useRealTimers();
|
||||
@@ -31,6 +42,24 @@ afterEach(() => {
|
||||
localStorage.clear();
|
||||
});
|
||||
|
||||
test.each(['/', '/dashboard', '/login', '/register', '/account', '/hosting/settings'])(
|
||||
'standalone %s never displays retained private Workspace notifications',
|
||||
(path) => {
|
||||
pushWorkspaceAlert('error', 'Private Workspace Orion: worker deletion failed');
|
||||
page.url = new URL(path, 'https://example.test');
|
||||
render(Layout, { props });
|
||||
expect(screen.getByText('Page content')).toBeTruthy();
|
||||
expect(screen.queryByText('Private Workspace Orion: worker deletion failed')).toBeNull();
|
||||
expect(screen.queryByRole('alert')).toBeNull();
|
||||
},
|
||||
);
|
||||
|
||||
test('Workspace shell continues displaying Workspace action notifications', () => {
|
||||
pushWorkspaceAlert('error', 'Worker deletion failed');
|
||||
render(Layout, { props });
|
||||
expect(screen.getByRole('alert').textContent).toContain('Worker deletion failed');
|
||||
});
|
||||
|
||||
test("Workspace catalog failure uses the shared alert without rendering diagnostics in the sidebar", async () => {
|
||||
render(Layout, {
|
||||
props: {
|
||||
|
||||
@@ -8,7 +8,8 @@ import {
|
||||
} from "@testing-library/svelte";
|
||||
import { afterEach, expect, test, vi } from "vitest";
|
||||
import { goto } from "$app/navigation";
|
||||
import Catalog from "./PageContent.svelte";
|
||||
import Catalog from "./dashboard/WorkspaceCatalog.svelte";
|
||||
import { WorkspaceCreation } from "./dashboard/workspace-creation.svelte.ts";
|
||||
import {
|
||||
createdWorkspace,
|
||||
creationResponse,
|
||||
@@ -20,15 +21,27 @@ afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
function mount() {
|
||||
return render(Catalog, {
|
||||
props: {
|
||||
data: {
|
||||
workspaces: [{ ...createdWorkspace, repositories: [] }],
|
||||
catalogError: null,
|
||||
async function mount() {
|
||||
const mutationFetch = globalThis.fetch;
|
||||
vi.stubGlobal("fetch", async (url: string, init?: RequestInit) => {
|
||||
if (init?.method === "POST") return mutationFetch(url, init);
|
||||
return Response.json(
|
||||
url.startsWith("/api/workspaces?") ? [createdWorkspace] : {
|
||||
workspace_id: "home-empty",
|
||||
items: [],
|
||||
source: "workspace-control-plane",
|
||||
diagnostics: [],
|
||||
},
|
||||
},
|
||||
);
|
||||
});
|
||||
const view = render(Catalog, {
|
||||
creation: new WorkspaceCreation("fixture-owner", () => true),
|
||||
});
|
||||
await screen.findByRole("link", { name: /New Workspace.*No repositories/ });
|
||||
await fireEvent.click(
|
||||
screen.getByText("Create Workspace", { selector: "summary" }),
|
||||
);
|
||||
return view;
|
||||
}
|
||||
function submit() {
|
||||
return fireEvent.submit(
|
||||
@@ -42,7 +55,7 @@ test("blank repository form creates and opens a Workspace using a null repositor
|
||||
Response.json(creationResponse()),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
mount();
|
||||
await mount();
|
||||
expect(
|
||||
screen.getByRole("link", { name: /New Workspace.*No repositories/ })
|
||||
.getAttribute("href"),
|
||||
@@ -64,7 +77,7 @@ test("opting into a repository requires an explicit key and source and submits n
|
||||
Response.json(creationResponse()),
|
||||
);
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
mount();
|
||||
await mount();
|
||||
await fireEvent.click(
|
||||
screen.getByRole("checkbox", {
|
||||
name: "Add an initial repository (optional)",
|
||||
@@ -98,7 +111,7 @@ test("unchanged empty creation retries reuse the operation key and accept delete
|
||||
)
|
||||
.mockResolvedValueOnce(Response.json(creationResponse(null, true)));
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
mount();
|
||||
await mount();
|
||||
await fireEvent.input(screen.getByLabelText("Workspace display name"), {
|
||||
target: { value: "New Workspace" },
|
||||
});
|
||||
@@ -116,7 +129,7 @@ test("opting out ignores retained repository inputs and changes the creation ope
|
||||
Response.json({ message: "Temporary failure" }, { status: 503 })
|
||||
);
|
||||
vi.stubGlobal("fetch", fetcher);
|
||||
mount();
|
||||
await mount();
|
||||
const toggle = screen.getByRole("checkbox", {
|
||||
name: "Add an initial repository (optional)",
|
||||
});
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
/// <reference lib="dom" />
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { resolve } from "@std/path";
|
||||
import { chromium, type Locator } from "playwright";
|
||||
import {
|
||||
startOwnedProcesses,
|
||||
stopOwnedProcesses,
|
||||
} from "../../../../tools/web-ux/src/processes.ts";
|
||||
const root = resolve(import.meta.dirname!, "../../../..");
|
||||
// 本番shellとclientを接続する合成HTTP/WebAuthn試験。Server認証/atomic保存のE2Eではない。
|
||||
Deno.test("activation standalone shell supports issuance, reissue, denial, restart and confirmed-only activation", async () => {
|
||||
const processes = await startOwnedProcesses(
|
||||
[{
|
||||
id: "activation-browser",
|
||||
command: "deno",
|
||||
cwd: root,
|
||||
args: [
|
||||
"run",
|
||||
"--config",
|
||||
"tools/web-ux/deno.json",
|
||||
"--allow-read",
|
||||
"--allow-net",
|
||||
"web/workspace/test/activation-browser/fixture-server.ts",
|
||||
"15381",
|
||||
"web/workspace/build",
|
||||
],
|
||||
readyUrl: "http://127.0.0.1:15381/health",
|
||||
}],
|
||||
`${root}/tools/web-ux/scenarios/hosting-settings-light.json`,
|
||||
`${root}/target/web-ux/activation-interactions/processes`,
|
||||
[],
|
||||
);
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
for (const colorScheme of ["light", "dark"] as const) {
|
||||
for (const width of [1440, 768, 390]) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height: 900 },
|
||||
colorScheme,
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
const requested: string[] = [];
|
||||
const errors: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
if (new URL(request.url()).pathname.startsWith("/api/")) {
|
||||
requested.push(
|
||||
`${request.method()} ${new URL(request.url()).pathname}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
page.on("pageerror", () => errors.push("pageerror"));
|
||||
const go = async (path: string, mode: string) => {
|
||||
await page.goto(`http://127.0.0.1:15381${path}?fixture=${mode}`);
|
||||
};
|
||||
const noOverflow = async () =>
|
||||
assertEquals(
|
||||
await page.evaluate(() =>
|
||||
document.documentElement.scrollWidth > innerWidth
|
||||
),
|
||||
false,
|
||||
);
|
||||
const linkStyle = (link: Locator) =>
|
||||
link.evaluate((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
const probe = document.createElement("span");
|
||||
probe.hidden = true;
|
||||
element.append(probe);
|
||||
const token = (name: string) => {
|
||||
probe.style.color = `var(${name})`;
|
||||
return getComputedStyle(probe).color;
|
||||
};
|
||||
const result = {
|
||||
color: style.color,
|
||||
outline: style.outlineColor,
|
||||
width: style.outlineWidth,
|
||||
kind: style.outlineStyle,
|
||||
underline: style.textDecorationLine,
|
||||
strong: token("--text-strong"),
|
||||
text: token("--text"),
|
||||
accent: token("--accent"),
|
||||
};
|
||||
probe.remove();
|
||||
return result;
|
||||
});
|
||||
const checkLink = async (link: Locator) => {
|
||||
await page.mouse.move(0, 0);
|
||||
const normal = await linkStyle(link);
|
||||
assertEquals(normal.color, normal.strong);
|
||||
assert(normal.underline.includes("underline"));
|
||||
await link.hover();
|
||||
const hovered = await linkStyle(link);
|
||||
assertEquals(hovered.color, hovered.text);
|
||||
await page.mouse.move(0, 0);
|
||||
await page.keyboard.press("Tab");
|
||||
await link.focus();
|
||||
const focused = await linkStyle(link);
|
||||
assertEquals(focused.kind, "solid");
|
||||
assertEquals(focused.width, "2px");
|
||||
assertEquals(focused.outline, focused.accent);
|
||||
};
|
||||
await go("/hosting/settings", "admin");
|
||||
await page.getByRole("link", {
|
||||
name: "Create accounts or reissue activation codes",
|
||||
}).click();
|
||||
await page.getByLabel("Account ID (handle)").waitFor();
|
||||
await checkLink(
|
||||
page.getByRole("link", { name: "Hosting settings", exact: true }),
|
||||
);
|
||||
const handle = page.getByLabel("Account ID (handle)");
|
||||
await handle.fill(" ALICE ");
|
||||
const issue = page.getByRole("button", {
|
||||
name: "Create account and issue code",
|
||||
});
|
||||
await handle.press("Tab");
|
||||
assert(
|
||||
await issue.evaluate((el) =>
|
||||
el === document.activeElement &&
|
||||
getComputedStyle(el).outlineStyle !== "none"
|
||||
),
|
||||
);
|
||||
await page.keyboard.press("Enter");
|
||||
await page.getByRole("heading", { name: "Activation code issued" })
|
||||
.waitFor();
|
||||
await checkLink(
|
||||
page.getByRole("link", { name: "/activate", exact: true }),
|
||||
);
|
||||
assertEquals(await page.locator(".secret").count(), 1);
|
||||
await noOverflow();
|
||||
await page.getByRole("button", { name: "Hide code" }).click();
|
||||
assertEquals(await page.locator(".secret").count(), 0);
|
||||
await page.getByLabel("Operation").selectOption("reissue");
|
||||
await page.getByRole("button", { name: "Reissue activation code" })
|
||||
.click();
|
||||
await page.getByRole("heading", { name: "Activation code issued" })
|
||||
.waitFor();
|
||||
assert(
|
||||
requested.includes("POST /api/hosting/accounts/activation-code"),
|
||||
);
|
||||
for (const mode of ["anonymous", "ordinary"]) {
|
||||
const before = requested.filter((path) =>
|
||||
path.startsWith("POST ")
|
||||
).length;
|
||||
await go("/hosting/accounts", mode);
|
||||
await page.getByText(
|
||||
mode === "anonymous"
|
||||
? "Sign in with a hosting administrator account to issue activation codes."
|
||||
: "Hosting administrator access is required.",
|
||||
).waitFor();
|
||||
assertEquals(
|
||||
await page.getByLabel("Account ID (handle)").count(),
|
||||
0,
|
||||
);
|
||||
assertEquals(
|
||||
requested.filter((path) => path.startsWith("POST ")).length,
|
||||
before,
|
||||
);
|
||||
await noOverflow();
|
||||
}
|
||||
const fillActivation = async () => {
|
||||
await page.getByLabel("Account ID (handle)").fill("alice");
|
||||
await page.getByLabel("Activation code", { exact: true }).fill(
|
||||
"synthetic-input-only",
|
||||
);
|
||||
await page.getByRole("button", {
|
||||
name: "Register Passkey and activate",
|
||||
}).click();
|
||||
};
|
||||
await go("/activate", "activation-pending");
|
||||
await page.getByLabel("Activation code", { exact: true }).waitFor();
|
||||
await checkLink(
|
||||
page.getByRole("link", {
|
||||
name:
|
||||
"Already registered or completion uncertain? Log in with a Passkey",
|
||||
}),
|
||||
);
|
||||
await fillActivation();
|
||||
await page.getByText(
|
||||
"Passkey registration pending. Account activation has not been confirmed.",
|
||||
).waitFor();
|
||||
assertEquals(
|
||||
await page.getByLabel("Activation code", { exact: true })
|
||||
.inputValue(),
|
||||
"",
|
||||
);
|
||||
assertEquals(
|
||||
requested.filter((path) =>
|
||||
path === "POST /api/auth/activation/complete"
|
||||
).length,
|
||||
0,
|
||||
);
|
||||
await page.getByRole("button", { name: "Cancel registration" })
|
||||
.click();
|
||||
await page.getByRole("alert").waitFor();
|
||||
assertEquals(await page.getByText(/You are signed in/).count(), 0);
|
||||
await noOverflow();
|
||||
await page.reload();
|
||||
await page.getByRole("button", {
|
||||
name: "Register Passkey and activate",
|
||||
}).waitFor();
|
||||
assertEquals(
|
||||
await page.getByLabel("Activation code", { exact: true })
|
||||
.inputValue(),
|
||||
"",
|
||||
);
|
||||
for (
|
||||
const mode of [
|
||||
"activation-cancel",
|
||||
"activation-unknown",
|
||||
"activation-reject",
|
||||
]
|
||||
) {
|
||||
await go("/activate", mode);
|
||||
await fillActivation();
|
||||
await page.getByRole("alert").waitFor();
|
||||
assertEquals(await page.getByText(/You are signed in/).count(), 0);
|
||||
await noOverflow();
|
||||
}
|
||||
await go("/activate", "activation-success");
|
||||
await fillActivation();
|
||||
await page.getByText(
|
||||
"Passkey registration and activation confirmed for alice. You are signed in.",
|
||||
).waitFor();
|
||||
assertEquals(await page.locator(".app-shell").count(), 0);
|
||||
await noOverflow();
|
||||
assertEquals(
|
||||
requested.some((path) => path.includes("/api/workspaces")),
|
||||
false,
|
||||
);
|
||||
assertEquals(
|
||||
requested.some((path) =>
|
||||
path.includes("/addition/") || path.includes("/registration/") ||
|
||||
path.includes("/device")
|
||||
),
|
||||
false,
|
||||
);
|
||||
assertEquals(
|
||||
await page.evaluate(() =>
|
||||
[...Object.values(localStorage), ...Object.values(sessionStorage)]
|
||||
.some((value) => value.includes("synthetic-"))
|
||||
),
|
||||
false,
|
||||
);
|
||||
assertEquals(new URL(page.url()).searchParams.has("code"), false);
|
||||
assertEquals(errors, []);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
await stopOwnedProcesses(processes);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,221 @@
|
||||
// 合成HTTPとWebAuthn境界だけを使い、本番build/shellをshared web-uxで採取する。
|
||||
// ServerE2Eとは別物。secret fieldは撮影前に隠し、snapshot/診断もredactする。
|
||||
import { resolve } from "@std/path";
|
||||
import { capture } from "../../../../tools/web-ux/src/capture.ts";
|
||||
import type {
|
||||
Interaction,
|
||||
RouteScenario,
|
||||
Scenario,
|
||||
} from "../../../../tools/web-ux/src/types.ts";
|
||||
const root = resolve(import.meta.dirname!, "../../../..");
|
||||
Deno.env.set("WEB_UX_SYNTHETIC_ACTIVATION_INPUT", "synthetic-input-only");
|
||||
const ready = (selector: string) => ({ kind: "selector" as const, selector });
|
||||
const click = (selector: string): Interaction => ({
|
||||
action: "click",
|
||||
selector,
|
||||
});
|
||||
const fill = (selector: string, value: string): Interaction => ({
|
||||
action: "fill",
|
||||
selector,
|
||||
value,
|
||||
});
|
||||
const activation = [
|
||||
fill("#activation-handle", "alice"),
|
||||
fill("#activation-code", "${WEB_UX_SYNTHETIC_ACTIVATION_INPUT}"),
|
||||
click('button:has-text("Register Passkey and activate")'),
|
||||
];
|
||||
const route = (
|
||||
id: string,
|
||||
path: string,
|
||||
selector: string,
|
||||
capturePoints: RouteScenario["capturePoints"] = [{
|
||||
id: "initial",
|
||||
label: id,
|
||||
fullPage: true,
|
||||
}],
|
||||
): RouteScenario => ({
|
||||
id,
|
||||
label: id,
|
||||
path,
|
||||
goal: "Check production shell and state/copy; not Server authorization.",
|
||||
dataState: `Synthetic ${id}. No real code, account, credential or session.`,
|
||||
ready: ready(selector),
|
||||
capturePoints,
|
||||
});
|
||||
const routes: RouteScenario[] = [
|
||||
route(
|
||||
"settings",
|
||||
"/hosting/settings?fixture=admin",
|
||||
'a[href="/hosting/accounts"]',
|
||||
),
|
||||
route("admin", "/hosting/accounts?fixture=admin", "#account-handle:enabled", [
|
||||
{ id: "initial", label: "ID-only creation", fullPage: true },
|
||||
{
|
||||
id: "link-focus",
|
||||
label: "Hosting settings link keyboard focus",
|
||||
interaction: [{ action: "press", selector: "summary", key: "Tab" }],
|
||||
ready: ready('a[href="/hosting/settings"]:focus-visible'),
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
id: "issued",
|
||||
label: "Issued; code redacted",
|
||||
interaction: [
|
||||
fill("#account-handle", "alice"),
|
||||
click('button:has-text("Create account and issue code")'),
|
||||
],
|
||||
ready: ready("#issued-heading"),
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
id: "reissue",
|
||||
label: "Explicit reissue and invalidation warning",
|
||||
interaction: [click('button:has-text("Hide code")'), {
|
||||
action: "press",
|
||||
selector: "#account-operation",
|
||||
key: "ArrowDown",
|
||||
}],
|
||||
ready: ready('button:has-text("Reissue activation code")'),
|
||||
fullPage: true,
|
||||
},
|
||||
]),
|
||||
route(
|
||||
"anonymous",
|
||||
"/hosting/accounts?fixture=anonymous",
|
||||
'a[href="/account"]',
|
||||
),
|
||||
route(
|
||||
"ordinary",
|
||||
"/hosting/accounts?fixture=ordinary",
|
||||
'p:has-text("Hosting administrator access is required.")',
|
||||
),
|
||||
route("loading", "/hosting/accounts?fixture=loading", '[role="status"]'),
|
||||
route(
|
||||
"access-error",
|
||||
"/hosting/accounts?fixture=access-error",
|
||||
'[role="alert"]',
|
||||
),
|
||||
route(
|
||||
"issue-error",
|
||||
"/hosting/accounts?fixture=issue-error",
|
||||
"#account-handle:enabled",
|
||||
[{
|
||||
id: "unconfirmed",
|
||||
label: "Intentional HTTP409; unconfirmed issuance",
|
||||
interaction: [
|
||||
fill("#account-handle", "alice"),
|
||||
click('button:has-text("Create account and issue code")'),
|
||||
],
|
||||
ready: ready('[role="alert"]'),
|
||||
fullPage: true,
|
||||
}],
|
||||
),
|
||||
route(
|
||||
"activation-pending",
|
||||
"/activate?fixture=activation-pending",
|
||||
"#activation-code:enabled",
|
||||
[
|
||||
{
|
||||
id: "initial",
|
||||
label: "ID and code before mandatory Passkey",
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
id: "link-focus",
|
||||
label: "Account recovery link keyboard focus",
|
||||
interaction: [{
|
||||
action: "press",
|
||||
selector: "summary",
|
||||
key: "Shift+Tab",
|
||||
}],
|
||||
ready: ready('a[href="/account"]:focus-visible'),
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
id: "pending",
|
||||
label: "Pending prompt, no activation claim",
|
||||
interaction: activation,
|
||||
ready: ready('button:has-text("Cancel registration")'),
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
id: "cancelled",
|
||||
label: "Cancelled; code cleared and restart guidance",
|
||||
interaction: [click('button:has-text("Cancel registration")')],
|
||||
ready: ready('[role="alert"]'),
|
||||
fullPage: true,
|
||||
},
|
||||
],
|
||||
),
|
||||
...[
|
||||
"activation-cancel",
|
||||
"activation-unknown",
|
||||
"activation-reject",
|
||||
"activation-success",
|
||||
].map((mode) =>
|
||||
route(mode, `/activate?fixture=${mode}`, "#activation-code:enabled", [{
|
||||
id: "result",
|
||||
label: mode,
|
||||
interaction: activation,
|
||||
ready: ready(
|
||||
mode === "activation-success" ? '[role="status"]' : '[role="alert"]',
|
||||
),
|
||||
fullPage: true,
|
||||
}])
|
||||
),
|
||||
];
|
||||
for (const colorScheme of ["light", "dark"] as const) {
|
||||
for (const width of [1440, 768, 390]) {
|
||||
const scenario: Scenario = {
|
||||
schemaVersion: 1,
|
||||
id: `activation-${colorScheme}-${width}`,
|
||||
title: "Activation synthetic projection on production build",
|
||||
baseUrl: "http://127.0.0.1:15382",
|
||||
colorScheme,
|
||||
reducedMotion: "reduce",
|
||||
personas: [{
|
||||
id: "fixture",
|
||||
label: "Synthetic HTTP/WebAuthn only",
|
||||
auth: { kind: "anonymous" },
|
||||
}],
|
||||
viewports: [{ width, height: 900, label: `${width}` }],
|
||||
routes,
|
||||
redact: {
|
||||
selectors: [".secret"],
|
||||
text: ["synthetic-output-only", "${WEB_UX_SYNTHETIC_ACTIVATION_INPUT}"],
|
||||
},
|
||||
processes: [{
|
||||
id: "activation-projection",
|
||||
command: "deno",
|
||||
cwd: root,
|
||||
args: [
|
||||
"run",
|
||||
"--config",
|
||||
"tools/web-ux/deno.json",
|
||||
"--allow-read",
|
||||
"--allow-net",
|
||||
"web/workspace/test/activation-browser/fixture-server.ts",
|
||||
"15382",
|
||||
"web/workspace/build",
|
||||
],
|
||||
readyUrl: "http://127.0.0.1:15382/health",
|
||||
}],
|
||||
};
|
||||
const scenarioPath =
|
||||
`${root}/target/web-ux/activation-${colorScheme}-${width}.json`;
|
||||
await Deno.writeTextFile(scenarioPath, JSON.stringify(scenario, null, 2));
|
||||
const result = await capture({
|
||||
scenarioPath,
|
||||
outputDirectory: `${root}/target/web-ux`,
|
||||
runId: `activation-after-v3-${colorScheme}-${width}`,
|
||||
});
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
colorScheme,
|
||||
width,
|
||||
status: result.status,
|
||||
captures: result.captures.length,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
// 本番buildを配信するUI投影fixture。実際のcode検証、認証、DB、cookie発行ではない。
|
||||
// request本文/credential/secretを記録しない。fixture cookieは公開状態名だけ。
|
||||
import { extname, join, resolve } from "@std/path";
|
||||
import { setupWebAuthnProjection } from "../hosting-browser/setup-projection.ts";
|
||||
const [port, build] = Deno.args;
|
||||
if (!port || !build) throw new Error("Expected port and build root");
|
||||
const root = resolve(build);
|
||||
const mime: Record<string, string> = {
|
||||
".html": "text/html",
|
||||
".js": "text/javascript",
|
||||
".css": "text/css",
|
||||
".json": "application/json",
|
||||
".svg": "image/svg+xml",
|
||||
".woff2": "font/woff2",
|
||||
".png": "image/png",
|
||||
};
|
||||
const user = {
|
||||
user_id: "fixture-user",
|
||||
account_id: "fixture-account",
|
||||
handle: "alice",
|
||||
display_name: "Alice",
|
||||
};
|
||||
Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === "/health") return new Response("ok");
|
||||
const selected = url.searchParams.get("fixture");
|
||||
const mode = selected ??
|
||||
request.headers.get("cookie")?.match(/activation-fixture=([\w-]+)/)?.[1] ??
|
||||
"admin";
|
||||
const path = url.pathname;
|
||||
if (path === "/__ui_fixture/webauthn.js") {
|
||||
const source = mode === "activation-pending"
|
||||
? `Object.defineProperty(navigator.credentials,'create',{configurable:true,value:async()=>new Promise(()=>{})});`
|
||||
: mode === "activation-cancel"
|
||||
? `Object.defineProperty(navigator.credentials,'create',{configurable:true,value:async()=>{throw new DOMException('Cancelled','NotAllowedError')}});`
|
||||
: setupWebAuthnProjection;
|
||||
return new Response(source, {
|
||||
headers: { "content-type": "text/javascript" },
|
||||
});
|
||||
}
|
||||
if (path === "/api/workspaces") return new Response(null, { status: 503 });
|
||||
if (path === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
actor: mode === "anonymous"
|
||||
? null
|
||||
: { ...user, auth_method: "browser_session" },
|
||||
});
|
||||
}
|
||||
if (path === "/api/hosting/access") {
|
||||
if (mode === "loading") return await new Promise<Response>(() => {});
|
||||
if (mode === "access-error") return new Response(null, { status: 503 });
|
||||
return Response.json({
|
||||
is_admin: mode !== "ordinary" && mode !== "anonymous",
|
||||
});
|
||||
}
|
||||
if (path === "/api/hosting/settings") {
|
||||
return Response.json({ self_registration_enabled: false });
|
||||
}
|
||||
if (
|
||||
path === "/api/hosting/accounts" ||
|
||||
path === "/api/hosting/accounts/activation-code"
|
||||
) {
|
||||
if (mode === "issue-error") return new Response(null, { status: 409 });
|
||||
// codeは暗号素材でない固定の投影値。captureは.secretを隠しsnapshotからも除外する。
|
||||
return Response.json({
|
||||
user,
|
||||
code: "synthetic-output-only",
|
||||
expires_at: new Date(Date.now() + 900000).toISOString(),
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/activation/options") {
|
||||
if (mode === "activation-reject") {
|
||||
return new Response(null, { status: 403 });
|
||||
}
|
||||
return Response.json({
|
||||
challenge_id: "ui-projection-only",
|
||||
public_key: {
|
||||
challenge: "dWk",
|
||||
rp: { name: "UI fixture" },
|
||||
user: { id: "dWk", name: "alice", displayName: "Alice" },
|
||||
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
|
||||
},
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/activation/complete") {
|
||||
if (mode === "activation-unknown") {
|
||||
return new Response(null, { status: 503 });
|
||||
}
|
||||
return Response.json({ user });
|
||||
}
|
||||
if (path.startsWith("/api/")) return new Response(null, { status: 404 });
|
||||
const file = join(root, decodeURIComponent(path));
|
||||
if (!file.startsWith(root + "/")) return new Response(null, { status: 400 });
|
||||
let bytes: Uint8Array;
|
||||
let extension = extname(file);
|
||||
try {
|
||||
bytes = await Deno.readFile(file);
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof Deno.errors.NotFound) &&
|
||||
!(error instanceof Deno.errors.IsADirectory)
|
||||
) throw error;
|
||||
bytes = await Deno.readFile(join(root, "index.html"));
|
||||
extension = ".html";
|
||||
}
|
||||
if (extension === ".html") {
|
||||
bytes = new TextEncoder().encode(
|
||||
new TextDecoder().decode(bytes).replace(
|
||||
"<head>",
|
||||
'<head><script src="/__ui_fixture/webauthn.js"></script>',
|
||||
),
|
||||
);
|
||||
}
|
||||
const headers = new Headers({
|
||||
"content-type": mime[extension] ?? "application/octet-stream",
|
||||
"cache-control": "no-store",
|
||||
});
|
||||
if (selected) {
|
||||
headers.set(
|
||||
"set-cookie",
|
||||
`activation-fixture=${selected}; Path=/; SameSite=Strict`,
|
||||
);
|
||||
}
|
||||
return new Response(bytes as Uint8Array<ArrayBuffer>, { headers });
|
||||
});
|
||||
@@ -0,0 +1,704 @@
|
||||
/// <reference lib="dom" />
|
||||
// 本番shell/router/clientを合成HTTP/WebAuthnで接続する。Server認証/DBのE2Eではない。
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { resolve } from "@std/path";
|
||||
import { type Browser, chromium, type Page } from "playwright";
|
||||
import {
|
||||
startOwnedProcesses,
|
||||
stopOwnedProcesses,
|
||||
} from "../../../../tools/web-ux/src/processes.ts";
|
||||
|
||||
const root = resolve(import.meta.dirname!, "../../../..");
|
||||
const origin = "http://127.0.0.1:15391";
|
||||
type Mutation = { path: string; keys: string[] };
|
||||
type Harness = {
|
||||
page: Page;
|
||||
mutations: Mutation[];
|
||||
go: (path: string, mode?: string) => Promise<void>;
|
||||
};
|
||||
const login = async (page: Page) => {
|
||||
await page.getByLabel("Account ID (handle)", { exact: true }).fill("alice");
|
||||
await page.getByRole("button", { name: "Log in with passkey", exact: true })
|
||||
.click();
|
||||
};
|
||||
const register = async (page: Page) => {
|
||||
await page.getByLabel("Account ID (handle)", { exact: true }).fill("alice");
|
||||
await page.getByLabel("Display name", { exact: true }).fill("Alice");
|
||||
await page.getByRole("button", { name: "Register new account", exact: true })
|
||||
.click();
|
||||
};
|
||||
const count = (mutations: Mutation[], path: string) =>
|
||||
mutations.filter((entry) => entry.path === path).length;
|
||||
const at = async (page: Page, path: string) => {
|
||||
await page.waitForURL((url) =>
|
||||
url.origin === origin && url.pathname + url.search === path
|
||||
);
|
||||
};
|
||||
const noOverflow = async (page: Page) =>
|
||||
assertEquals(
|
||||
await page.evaluate(() =>
|
||||
document.documentElement.scrollWidth > innerWidth
|
||||
),
|
||||
false,
|
||||
);
|
||||
async function isolated(
|
||||
browser: Browser,
|
||||
body: (harness: Harness) => Promise<void>,
|
||||
colorScheme: "light" | "dark" = "light",
|
||||
width = 1440,
|
||||
) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height: 900 },
|
||||
colorScheme,
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
try {
|
||||
// 不正returnToでも外部へ接続しない。遷移した事実は下のURL assertionで失敗する。
|
||||
await context.route("**/*", (route) => {
|
||||
const url = new URL(route.request().url());
|
||||
return url.origin === origin ? route.continue() : route.abort();
|
||||
});
|
||||
const page = await context.newPage();
|
||||
const mutations: Mutation[] = [];
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
page.on("request", (request) => {
|
||||
const path = new URL(request.url()).pathname;
|
||||
if (request.method() === "POST" && path.startsWith("/api/")) {
|
||||
// credential本文/secretは保持せず、対象を自由指定しない契約のfield名だけを観測する。
|
||||
mutations.push({
|
||||
path,
|
||||
keys: Object.keys(request.postDataJSON() ?? {}).sort(),
|
||||
});
|
||||
}
|
||||
});
|
||||
await body({
|
||||
page,
|
||||
mutations,
|
||||
go: async (path, mode = "anonymous") => {
|
||||
const url = new URL(path, origin);
|
||||
url.searchParams.set("fixture", mode);
|
||||
await page.goto(url.href);
|
||||
},
|
||||
});
|
||||
await noOverflow(page);
|
||||
assertEquals(errors, []);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
|
||||
Deno.test("standalone auth separates login, registration, own account and explicit device approval", async (t) => {
|
||||
const processes = await startOwnedProcesses(
|
||||
[{
|
||||
id: "auth-browser",
|
||||
command: "deno",
|
||||
cwd: root,
|
||||
args: [
|
||||
"run",
|
||||
"--config",
|
||||
"tools/web-ux/deno.json",
|
||||
"--allow-read",
|
||||
"--allow-net",
|
||||
"web/workspace/test/auth-browser/fixture-server.ts",
|
||||
"15391",
|
||||
"web/workspace/build",
|
||||
],
|
||||
readyUrl: `${origin}/health`,
|
||||
}],
|
||||
`${root}/tools/web-ux/scenarios/anonymous-entry.json`,
|
||||
`${root}/target/web-ux/auth-interactions/processes`,
|
||||
[],
|
||||
);
|
||||
let browser: Browser | undefined;
|
||||
try {
|
||||
browser = await chromium.launch();
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
for (const width of [1440, 768, 390]) {
|
||||
const step = (
|
||||
name: string,
|
||||
body: (harness: Harness) => Promise<void>,
|
||||
) =>
|
||||
t.step(
|
||||
`${theme}/${width}: ${name}`,
|
||||
() => isolated(browser!, body, theme, width),
|
||||
);
|
||||
await step(
|
||||
"anonymous account replaces history with login then returns to own account",
|
||||
async ({ page, go, mutations }) => {
|
||||
await page.goto(`${origin}/health`);
|
||||
const historyBefore = await page.evaluate(() => history.length);
|
||||
await go("/account");
|
||||
await at(page, "/login?returnTo=%2Faccount");
|
||||
assertEquals(
|
||||
await page.evaluate(() => history.length),
|
||||
historyBefore + 1,
|
||||
);
|
||||
const id = page.getByLabel("Account ID (handle)", { exact: true });
|
||||
await id.waitFor();
|
||||
assertEquals(
|
||||
await page.getByLabel("Display name", { exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
assertEquals(await page.locator("input").count(), 1);
|
||||
assertEquals(
|
||||
await page.getByRole("link", {
|
||||
name: "Create a new account",
|
||||
exact: true,
|
||||
}).getAttribute("href"),
|
||||
"/register?returnTo=%2Faccount",
|
||||
);
|
||||
await page.getByRole("link", {
|
||||
name: "Activate an account with a code",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
await id.fill("alice");
|
||||
await id.press("Tab");
|
||||
const button = page.getByRole("button", {
|
||||
name: "Log in with passkey",
|
||||
exact: true,
|
||||
});
|
||||
assert(
|
||||
await button.evaluate((element) =>
|
||||
element === document.activeElement &&
|
||||
getComputedStyle(element).outlineStyle !== "none"
|
||||
),
|
||||
);
|
||||
await page.keyboard.press("Enter");
|
||||
await at(page, "/account");
|
||||
await page.getByRole("button", {
|
||||
name: "Add passkey to alice",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
assertEquals(await page.locator("input").count(), 0);
|
||||
await page.getByText("Alice", { exact: true }).first().waitFor();
|
||||
await noOverflow(page);
|
||||
await page.getByRole("button", {
|
||||
name: "Add passkey to alice",
|
||||
exact: true,
|
||||
}).click();
|
||||
await page.getByRole("status").filter({
|
||||
hasText: /added|registered/i,
|
||||
}).waitFor();
|
||||
const additions = mutations.filter((entry) =>
|
||||
entry.path.includes("/addition/")
|
||||
);
|
||||
assertEquals(additions.map((entry) => entry.path), [
|
||||
"/api/auth/passkeys/addition/options",
|
||||
"/api/auth/passkeys/addition/complete",
|
||||
]);
|
||||
assertEquals(additions[0].keys, ["browser_origin"]);
|
||||
assertEquals(additions[1].keys, ["challenge_id", "credential"]);
|
||||
assertEquals(
|
||||
mutations.some((entry) => entry.path.includes("/registration/")),
|
||||
false,
|
||||
);
|
||||
await page.getByRole("button", { name: "Log out", exact: true })
|
||||
.click();
|
||||
await page.getByRole("button", {
|
||||
name: "Log in with passkey",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
assertEquals(new URL(page.url()).pathname, "/login");
|
||||
await page.reload();
|
||||
await page.getByRole("button", {
|
||||
name: "Log in with passkey",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
assertEquals(count(mutations, "/api/auth/logout"), 1);
|
||||
},
|
||||
);
|
||||
await step(
|
||||
"direct registration has only ID and display name and returns safely",
|
||||
async ({ page, go, mutations }) => {
|
||||
await go("/register?returnTo=%2Faccount");
|
||||
await page.getByLabel("Display name", { exact: true }).waitFor();
|
||||
assertEquals(await page.locator("input").count(), 2);
|
||||
await register(page);
|
||||
await at(page, "/account");
|
||||
await page.getByRole("button", {
|
||||
name: "Add passkey to alice",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
assertEquals(
|
||||
count(mutations, "/api/auth/passkeys/registration/options"),
|
||||
1,
|
||||
);
|
||||
assertEquals(
|
||||
count(mutations, "/api/auth/passkeys/registration/complete"),
|
||||
1,
|
||||
);
|
||||
assertEquals(
|
||||
mutations.some((entry) => entry.path.includes("/addition/")),
|
||||
false,
|
||||
);
|
||||
},
|
||||
);
|
||||
await step(
|
||||
"device login restoration never approves without an explicit separate action",
|
||||
async ({ page, go, mutations }) => {
|
||||
await go("/login/device?user_code=ABCD-EFGH");
|
||||
await page.getByLabel("User code", { exact: true }).fill(
|
||||
"WXYZ-1234",
|
||||
);
|
||||
const entry = page.getByRole("link", {
|
||||
name: "Log in to continue",
|
||||
exact: true,
|
||||
});
|
||||
const href = await entry.getAttribute("href");
|
||||
assert(href);
|
||||
const returned = new URL(href, origin).searchParams.get("returnTo");
|
||||
assert(returned?.startsWith("/login/device?"));
|
||||
assertEquals(
|
||||
new URL(returned!, origin).searchParams.get("user_code"),
|
||||
"WXYZ-1234",
|
||||
);
|
||||
assertEquals(
|
||||
await page.getByLabel("Account ID (handle)", { exact: true })
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
assertEquals(count(mutations, "/api/auth/device-login/approve"), 0);
|
||||
await entry.click();
|
||||
await login(page);
|
||||
await page.waitForURL((url) => url.pathname === "/login/device");
|
||||
const approve = page.getByRole("button", {
|
||||
name: "Approve device login",
|
||||
exact: true,
|
||||
});
|
||||
await approve.waitFor();
|
||||
assertEquals(
|
||||
await page.getByLabel("User code", { exact: true }).inputValue(),
|
||||
"WXYZ-1234",
|
||||
);
|
||||
assertEquals(count(mutations, "/api/auth/device-login/approve"), 0);
|
||||
await approve.click();
|
||||
await page.getByText(
|
||||
"Device login approved. You can return to the CLI.",
|
||||
{ exact: true },
|
||||
).waitFor();
|
||||
assertEquals(count(mutations, "/api/auth/device-login/approve"), 1);
|
||||
},
|
||||
);
|
||||
await step(
|
||||
"disabled registration retains existing-user login",
|
||||
async ({ page, go, mutations }) => {
|
||||
await go("/register", "policy-disabled");
|
||||
await page.getByText(
|
||||
"New account registration is disabled. Existing users can still log in.",
|
||||
{ exact: true },
|
||||
).waitFor();
|
||||
assertEquals(
|
||||
await page.getByRole("button", {
|
||||
name: "Register new account",
|
||||
exact: true,
|
||||
}).count(),
|
||||
0,
|
||||
);
|
||||
assertEquals(
|
||||
await page.getByLabel("Account ID (handle)", { exact: true })
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
const link = page.locator('a[href^="/login"]').first();
|
||||
await link.click();
|
||||
await login(page);
|
||||
await at(page, "/dashboard");
|
||||
await page.goto(`${origin}/account`);
|
||||
await page.getByRole("button", {
|
||||
name: "Add passkey to alice",
|
||||
exact: true,
|
||||
}).click();
|
||||
await page.getByRole("status").filter({ hasText: /added/i })
|
||||
.waitFor();
|
||||
assertEquals(
|
||||
count(mutations, "/api/auth/passkeys/addition/complete"),
|
||||
1,
|
||||
);
|
||||
assertEquals(
|
||||
mutations.some((entry) => entry.path.includes("/registration/")),
|
||||
false,
|
||||
);
|
||||
},
|
||||
);
|
||||
await step(
|
||||
"registration stopped during ceremony displays HTTP403 and removes registration after policy refresh",
|
||||
async ({ page, go, mutations }) => {
|
||||
await go("/register", "policy-stopped");
|
||||
await register(page);
|
||||
await page.getByRole("alert").filter({ hasText: "403" }).waitFor();
|
||||
await page.getByText(
|
||||
"New account registration is disabled. Existing users can still log in.",
|
||||
{ exact: true },
|
||||
).waitFor();
|
||||
assertEquals(await page.locator("form").count(), 0);
|
||||
assertEquals(
|
||||
await page.getByRole("button", {
|
||||
name: "Register new account",
|
||||
exact: true,
|
||||
}).count(),
|
||||
0,
|
||||
);
|
||||
await page.getByRole("link", {
|
||||
name: "Log in with an existing account",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
assertEquals(new URL(page.url()).pathname, "/register");
|
||||
assertEquals(
|
||||
count(mutations, "/api/auth/passkeys/registration/complete"),
|
||||
1,
|
||||
);
|
||||
await noOverflow(page);
|
||||
},
|
||||
);
|
||||
for (
|
||||
const mode of [
|
||||
"session-error",
|
||||
"session-malformed",
|
||||
"session-loading",
|
||||
]
|
||||
) {
|
||||
await step(
|
||||
`${mode} does not become known anonymous or allow mutation`,
|
||||
async ({ page, go, mutations }) => {
|
||||
await go("/login", mode);
|
||||
if (mode === "session-loading") {
|
||||
await page.getByText("Loading session…", { exact: true })
|
||||
.waitFor();
|
||||
} else {
|
||||
await page.getByRole("alert").waitFor();
|
||||
await page.getByRole("button", {
|
||||
name: "Retry session",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
}
|
||||
const loginButton = page.getByRole("button", {
|
||||
name: "Log in with passkey",
|
||||
exact: true,
|
||||
});
|
||||
if (await loginButton.count()) {
|
||||
assertEquals(await loginButton.isEnabled(), false);
|
||||
}
|
||||
assertEquals(mutations, []);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
for (
|
||||
const path of [
|
||||
"/account",
|
||||
"/register",
|
||||
"/login/device?user_code=ABCD-EFGH",
|
||||
]
|
||||
) {
|
||||
await t.step(
|
||||
`unknown session on ${path} offers only recovery, never anonymous mutations`,
|
||||
() =>
|
||||
isolated(browser!, async ({ page, go, mutations }) => {
|
||||
await go(path, "session-error");
|
||||
await page.getByRole("button", {
|
||||
name: "Retry session",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
assertEquals(
|
||||
await page.getByRole("link", {
|
||||
name: "Log in to continue",
|
||||
exact: true,
|
||||
}).count(),
|
||||
0,
|
||||
);
|
||||
assertEquals(
|
||||
await page.getByLabel("Account ID (handle)", { exact: true })
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
assertEquals(
|
||||
await page.getByRole("button", {
|
||||
name:
|
||||
/^(Add passkey|Register new account|Approve device login)/,
|
||||
}).count(),
|
||||
0,
|
||||
);
|
||||
assertEquals(
|
||||
new URL(page.url()).pathname,
|
||||
new URL(path, origin).pathname,
|
||||
);
|
||||
assertEquals(mutations, []);
|
||||
}),
|
||||
);
|
||||
}
|
||||
await t.step(
|
||||
"policy lookup failure is unknown availability, not disabled or permitted registration",
|
||||
() =>
|
||||
isolated(browser!, async ({ page, go, mutations }) => {
|
||||
await go("/register", "policy-error");
|
||||
await page.getByRole("alert").waitFor();
|
||||
assertEquals(
|
||||
await page.getByRole("button", {
|
||||
name: "Register new account",
|
||||
exact: true,
|
||||
}).count(),
|
||||
0,
|
||||
);
|
||||
await page.getByRole("link", {
|
||||
name: "Log in with an existing account",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
assertEquals(mutations, []);
|
||||
}),
|
||||
);
|
||||
await t.step(
|
||||
"leaving a pending login never completes or redirects after navigation",
|
||||
() =>
|
||||
isolated(browser!, async ({ page, go, mutations }) => {
|
||||
await go("/login", "login-pending");
|
||||
await login(page);
|
||||
await page.waitForFunction(() =>
|
||||
typeof (window as unknown as { __authFixtureRelease?: unknown })
|
||||
.__authFixtureRelease === "function"
|
||||
);
|
||||
await page.getByRole("link", {
|
||||
name: "Create a new account",
|
||||
exact: true,
|
||||
}).click();
|
||||
await page.getByLabel("Display name", { exact: true }).waitFor();
|
||||
await page.evaluate(() =>
|
||||
(window as unknown as { __authFixtureRelease: () => void })
|
||||
.__authFixtureRelease()
|
||||
);
|
||||
// 次のpaintまで旧promiseのmicrotaskを進め、固定sleepで完了を推測しない。
|
||||
await page.evaluate(() =>
|
||||
new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() =>
|
||||
requestAnimationFrame(() => resolve())
|
||||
)
|
||||
)
|
||||
);
|
||||
assertEquals(new URL(page.url()).pathname, "/register");
|
||||
assertEquals(
|
||||
count(mutations, "/api/auth/passkeys/login/complete"),
|
||||
0,
|
||||
);
|
||||
}),
|
||||
);
|
||||
await t.step(
|
||||
"retry rechecks unknown session before allowing login",
|
||||
() =>
|
||||
isolated(browser!, async ({ page, go }) => {
|
||||
await go("/login", "session-retry");
|
||||
await page.getByRole("button", { name: "Retry session", exact: true })
|
||||
.click();
|
||||
await login(page);
|
||||
await at(page, "/dashboard");
|
||||
}),
|
||||
);
|
||||
for (const route of ["login", "register"]) {
|
||||
await t.step(
|
||||
`${route} without returnTo uses root fallback`,
|
||||
() =>
|
||||
isolated(browser!, async ({ page, go }) => {
|
||||
await go(`/${route}`);
|
||||
await (route === "login" ? login(page) : register(page));
|
||||
await at(page, "/dashboard");
|
||||
await page.reload();
|
||||
assertEquals(new URL(page.url()).pathname, "/dashboard");
|
||||
}),
|
||||
);
|
||||
for (
|
||||
const rejected of [
|
||||
"https://example.invalid/",
|
||||
"//example.invalid/",
|
||||
"/\\example.invalid/",
|
||||
"account",
|
||||
"/login",
|
||||
"/register?returnTo=%2Flogin",
|
||||
"/activate",
|
||||
"/login/../login",
|
||||
]
|
||||
) {
|
||||
await t.step(
|
||||
`${route} rejects external or auth-loop returnTo ${rejected}`,
|
||||
() =>
|
||||
isolated(browser!, async ({ page, go }) => {
|
||||
await go(`/${route}?returnTo=${encodeURIComponent(rejected)}`);
|
||||
await (route === "login" ? login(page) : register(page));
|
||||
await at(page, "/dashboard");
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
for (const purpose of ["login", "registration", "addition"] as const) {
|
||||
const route = purpose === "registration"
|
||||
? "/register"
|
||||
: purpose === "addition"
|
||||
? "/account"
|
||||
: "/login";
|
||||
for (const failure of ["cancel", "error", "http-error", "expired"]) {
|
||||
await t.step(
|
||||
`${purpose} ${failure} is visible and does not claim successful authentication`,
|
||||
() =>
|
||||
isolated(browser!, async ({ page, go, mutations }) => {
|
||||
await go(route, `${purpose}-${failure}`);
|
||||
if (purpose === "login") await login(page);
|
||||
else if (purpose === "registration") await register(page);
|
||||
else {await page.getByRole("button", {
|
||||
name: "Add passkey to alice",
|
||||
exact: true,
|
||||
}).click();}
|
||||
await page.getByRole("alert").waitFor();
|
||||
const path = new URL(page.url()).pathname;
|
||||
assert(
|
||||
path === route || (purpose === "addition" && path === "/login"),
|
||||
);
|
||||
if (
|
||||
failure === "cancel" || failure === "error" ||
|
||||
failure === "http-error"
|
||||
) {
|
||||
assertEquals(
|
||||
count(mutations, `/api/auth/passkeys/${purpose}/complete`),
|
||||
0,
|
||||
);
|
||||
} else {assertEquals(
|
||||
count(mutations, `/api/auth/passkeys/${purpose}/complete`),
|
||||
1,
|
||||
);}
|
||||
}),
|
||||
);
|
||||
}
|
||||
await t.step(
|
||||
`${purpose} repeated submit while prompt is pending sends one ceremony`,
|
||||
() =>
|
||||
isolated(browser!, async ({ page, go, mutations }) => {
|
||||
await go(route, `${purpose}-pending`);
|
||||
const label = purpose === "login"
|
||||
? "Log in with passkey"
|
||||
: purpose === "registration"
|
||||
? "Register new account"
|
||||
: "Add passkey to alice";
|
||||
if (purpose === "login") await login(page);
|
||||
else if (purpose === "registration") await register(page);
|
||||
else {await page.getByRole("button", { name: label, exact: true })
|
||||
.click();}
|
||||
await page.waitForFunction(() =>
|
||||
typeof (window as unknown as { __authFixtureRelease?: unknown })
|
||||
.__authFixtureRelease === "function"
|
||||
);
|
||||
assertEquals(
|
||||
count(mutations, `/api/auth/passkeys/${purpose}/options`),
|
||||
1,
|
||||
);
|
||||
assertEquals(
|
||||
count(mutations, `/api/auth/passkeys/${purpose}/complete`),
|
||||
0,
|
||||
);
|
||||
await page.getByRole("button", { name: label, exact: true })
|
||||
.evaluate((button) => {
|
||||
button.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true }),
|
||||
);
|
||||
button.dispatchEvent(
|
||||
new MouseEvent("click", { bubbles: true }),
|
||||
);
|
||||
});
|
||||
await page.locator("form").evaluateAll((forms) => {
|
||||
for (const form of forms) {
|
||||
form.dispatchEvent(
|
||||
new Event("submit", { bubbles: true, cancelable: true }),
|
||||
);
|
||||
form.dispatchEvent(
|
||||
new Event("submit", { bubbles: true, cancelable: true }),
|
||||
);
|
||||
}
|
||||
});
|
||||
await page.evaluate(() =>
|
||||
(window as unknown as { __authFixtureRelease: () => void })
|
||||
.__authFixtureRelease()
|
||||
);
|
||||
if (purpose === "addition") {
|
||||
await page.getByRole("status").filter({
|
||||
hasText: /added|registered/i,
|
||||
}).waitFor();
|
||||
} else await at(page, "/dashboard");
|
||||
assertEquals(
|
||||
count(mutations, `/api/auth/passkeys/${purpose}/options`),
|
||||
1,
|
||||
);
|
||||
assertEquals(
|
||||
count(mutations, `/api/auth/passkeys/${purpose}/complete`),
|
||||
1,
|
||||
);
|
||||
}),
|
||||
);
|
||||
}
|
||||
await t.step(
|
||||
"expired device request stays unapproved",
|
||||
() =>
|
||||
isolated(browser!, async ({ page, go, mutations }) => {
|
||||
await go("/login/device?user_code=ABCD-EFGH", "device-expired");
|
||||
await page.getByRole("link", {
|
||||
name: "Log in to continue",
|
||||
exact: true,
|
||||
}).click();
|
||||
await login(page);
|
||||
await page.getByRole("button", {
|
||||
name: "Approve device login",
|
||||
exact: true,
|
||||
}).click();
|
||||
await page.getByRole("alert").filter({ hasText: /invalid|expired/i })
|
||||
.waitFor();
|
||||
assertEquals(count(mutations, "/api/auth/device-login/approve"), 1);
|
||||
}),
|
||||
);
|
||||
await t.step(
|
||||
"activation entry remains limited until credential completion",
|
||||
() =>
|
||||
isolated(browser!, async ({ page, go, mutations }) => {
|
||||
await go("/login", "activation-pending");
|
||||
await page.getByRole("link", {
|
||||
name: "Activate an account with a code",
|
||||
exact: true,
|
||||
}).click();
|
||||
// 同名labelのlogin入力が残る間にfillしない。遷移先の固有controlで同期する。
|
||||
await page.waitForURL((url) => url.pathname === "/activate");
|
||||
await page.locator("#activation-handle").fill("alice");
|
||||
await page.locator("#activation-code").fill(
|
||||
"synthetic-input-only",
|
||||
);
|
||||
await page.getByRole("button", {
|
||||
name: "Register Passkey and activate",
|
||||
exact: true,
|
||||
}).click();
|
||||
await page.getByText(
|
||||
"Passkey registration pending. Account activation has not been confirmed.",
|
||||
{ exact: true },
|
||||
).waitFor();
|
||||
assertEquals(count(mutations, "/api/auth/activation/complete"), 0);
|
||||
assertEquals(
|
||||
mutations.some((entry) =>
|
||||
/device-login|addition|registration/.test(entry.path)
|
||||
),
|
||||
false,
|
||||
);
|
||||
assertEquals(await page.locator(".app-shell").count(), 0);
|
||||
await page.getByRole("button", {
|
||||
name: "Cancel registration",
|
||||
exact: true,
|
||||
}).click();
|
||||
await page.getByRole("alert").waitFor();
|
||||
const link = page.locator('a[href="/login"]');
|
||||
await link.waitFor();
|
||||
await link.click();
|
||||
await page.waitForURL((url) => url.pathname === "/login");
|
||||
await page.getByRole("button", {
|
||||
name: "Log in with passkey",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
assertEquals(count(mutations, "/api/auth/device-login/approve"), 0);
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await browser?.close();
|
||||
await stopOwnedProcesses(processes);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,327 @@
|
||||
// shared captureがscreenshot/snapshot/review-contextを採取する。合否と本人目視は呼出元が行う。
|
||||
// 本番buildに合成HTTP/WebAuthnだけを接続し、撮影専用CSSや認証情報は使わない。
|
||||
import { resolve } from "@std/path";
|
||||
import { capture } from "../../../../tools/web-ux/src/capture.ts";
|
||||
import type {
|
||||
Interaction,
|
||||
RouteScenario,
|
||||
Scenario,
|
||||
} from "../../../../tools/web-ux/src/types.ts";
|
||||
|
||||
const root = resolve(import.meta.dirname!, "../../../..");
|
||||
const phase = Deno.args[0] ?? "after";
|
||||
if (!/^[a-zA-Z0-9_-]+$/.test(phase)) {
|
||||
throw new Error("Expected safe capture label");
|
||||
}
|
||||
const ready = (selector: string) => ({ kind: "selector" as const, selector });
|
||||
const click = (selector: string): Interaction => ({
|
||||
action: "click",
|
||||
selector,
|
||||
});
|
||||
const fill = (selector: string, value: string): Interaction => ({
|
||||
action: "fill",
|
||||
selector,
|
||||
value,
|
||||
});
|
||||
const id = 'label:has-text("ID (handle)") input';
|
||||
const login = [
|
||||
fill(id, "alice"),
|
||||
click('button:text-is("Log in with passkey")'),
|
||||
];
|
||||
const register = [
|
||||
fill(id, "alice"),
|
||||
fill('label:has-text("Display name") input', "Alice"),
|
||||
click('button:text-is("Register new account")'),
|
||||
];
|
||||
const route = (
|
||||
id: string,
|
||||
path: string,
|
||||
selector: string,
|
||||
capturePoints: RouteScenario["capturePoints"] = [{
|
||||
id: "initial",
|
||||
label: id,
|
||||
fullPage: true,
|
||||
}],
|
||||
): RouteScenario => ({
|
||||
id,
|
||||
label: id,
|
||||
path,
|
||||
goal:
|
||||
"Review standalone production auth UI, navigation, focus, state and copy; not Server authorization.",
|
||||
dataState: `Synthetic ${id}; no real session, credential or activation code.`,
|
||||
ready: ready(selector),
|
||||
capturePoints,
|
||||
});
|
||||
const routes: RouteScenario[] = [
|
||||
route("login", "/login?fixture=anonymous", `${id}:enabled`, [
|
||||
{
|
||||
id: "initial",
|
||||
label: "ID-only login; registration and activation entries",
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
id: "keyboard-focus",
|
||||
label: "Login keyboard focus",
|
||||
fullPage: true,
|
||||
interaction: [fill(id, "alice"), {
|
||||
action: "press",
|
||||
selector: id,
|
||||
key: "Tab",
|
||||
}],
|
||||
ready: ready('button:text-is("Log in with passkey"):focus-visible'),
|
||||
},
|
||||
]),
|
||||
route(
|
||||
"account-redirect",
|
||||
"/account?fixture=anonymous",
|
||||
'button:text-is("Log in with passkey")',
|
||||
),
|
||||
route("register", "/register?fixture=anonymous", `${id}:enabled`, [
|
||||
{
|
||||
id: "initial",
|
||||
label: "ID and distinct display name; new account only",
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
id: "keyboard-focus",
|
||||
label: "Register keyboard focus",
|
||||
fullPage: true,
|
||||
interaction: [
|
||||
fill(id, "alice"),
|
||||
fill('label:has-text("Display name") input', "Alice"),
|
||||
{
|
||||
action: "press",
|
||||
selector: 'label:has-text("Display name") input',
|
||||
key: "Tab",
|
||||
},
|
||||
],
|
||||
ready: ready('button:text-is("Register new account"):focus-visible'),
|
||||
},
|
||||
]),
|
||||
route(
|
||||
"account",
|
||||
"/account?fixture=account",
|
||||
'button:text-is("Add passkey to alice")',
|
||||
[
|
||||
{
|
||||
id: "initial",
|
||||
label: "Own account only; no target handle field",
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
id: "addition-confirmed",
|
||||
label: "Own Passkey addition confirmed",
|
||||
fullPage: true,
|
||||
interaction: [click('button:text-is("Add passkey to alice")')],
|
||||
ready: ready('[role="status"]:has-text("Passkey added")'),
|
||||
},
|
||||
{
|
||||
id: "logout",
|
||||
label: "Log out returns to login",
|
||||
fullPage: true,
|
||||
interaction: [click('button:text-is("Log out")')],
|
||||
ready: ready('button:text-is("Log in with passkey")'),
|
||||
},
|
||||
],
|
||||
),
|
||||
route(
|
||||
"device-anonymous",
|
||||
"/login/device?user_code=ABCD-EFGH&fixture=anonymous",
|
||||
'a:text-is("Log in to continue")',
|
||||
[
|
||||
{
|
||||
id: "initial",
|
||||
label: "User code and login continuation; no implicit approval",
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
id: "login",
|
||||
label: "Dedicated login with device returnTo",
|
||||
fullPage: true,
|
||||
interaction: [click('a:text-is("Log in to continue")')],
|
||||
ready: ready(`${id}:enabled`),
|
||||
},
|
||||
{
|
||||
id: "authenticated-unapproved",
|
||||
label: "Login complete; explicit approval still required",
|
||||
fullPage: true,
|
||||
interaction: login,
|
||||
ready: ready('button:text-is("Approve device login"):enabled'),
|
||||
},
|
||||
{
|
||||
id: "approved",
|
||||
label: "Separate explicit approval confirmed",
|
||||
fullPage: true,
|
||||
interaction: [click('button:text-is("Approve device login")')],
|
||||
ready: ready(
|
||||
'text="Device login approved. You can return to the CLI."',
|
||||
),
|
||||
},
|
||||
],
|
||||
),
|
||||
route(
|
||||
"session-loading",
|
||||
"/login?fixture=session-loading",
|
||||
'text="Loading session…"',
|
||||
),
|
||||
route(
|
||||
"session-error",
|
||||
"/login?fixture=session-error",
|
||||
'button:text-is("Retry session")',
|
||||
),
|
||||
route(
|
||||
"account-session-error",
|
||||
"/account?fixture=session-error",
|
||||
'button:text-is("Retry session")',
|
||||
),
|
||||
route(
|
||||
"register-session-error",
|
||||
"/register?fixture=session-error",
|
||||
'button:text-is("Retry session")',
|
||||
),
|
||||
route(
|
||||
"device-session-error",
|
||||
"/login/device?user_code=ABCD-EFGH&fixture=session-error",
|
||||
'button:text-is("Retry session")',
|
||||
),
|
||||
route(
|
||||
"policy-disabled",
|
||||
"/register?fixture=policy-disabled",
|
||||
'text="New account registration is disabled. Existing users can still log in."',
|
||||
),
|
||||
route("policy-error", "/register?fixture=policy-error", '[role="alert"]'),
|
||||
route("policy-stopped", "/register?fixture=policy-stopped", `${id}:enabled`, [
|
||||
{
|
||||
id: "completion-403",
|
||||
label:
|
||||
"Policy refreshed to disabled after intentional registration HTTP403",
|
||||
fullPage: true,
|
||||
interaction: register,
|
||||
ready: ready(
|
||||
'text="New account registration is disabled. Existing users can still log in."',
|
||||
),
|
||||
},
|
||||
]),
|
||||
...["login", "registration", "addition"].flatMap((purpose) =>
|
||||
["pending", "cancel", "error", "expired"].map((state) => {
|
||||
const path = purpose === "registration"
|
||||
? "/register"
|
||||
: purpose === "addition"
|
||||
? "/account"
|
||||
: "/login";
|
||||
const interaction = purpose === "login"
|
||||
? login
|
||||
: purpose === "registration"
|
||||
? register
|
||||
: [click('button:text-is("Add passkey to alice")')];
|
||||
return route(
|
||||
`${purpose}-${state}`,
|
||||
`${path}?fixture=${purpose}-${state}`,
|
||||
purpose === "addition"
|
||||
? 'button:text-is("Add passkey to alice")'
|
||||
: `${id}:enabled`,
|
||||
[{
|
||||
id: "result",
|
||||
label: `${purpose} ${state}; not a successful ceremony`,
|
||||
fullPage: true,
|
||||
interaction,
|
||||
ready: ready(
|
||||
state === "pending" ? '[role="status"]' : '[role="alert"]',
|
||||
),
|
||||
}],
|
||||
);
|
||||
})
|
||||
),
|
||||
route(
|
||||
"activation-entry",
|
||||
"/login?fixture=activation-pending",
|
||||
'a:text-is("Activate an account with a code")',
|
||||
[
|
||||
{
|
||||
id: "entry",
|
||||
label: "Existing activation route from login",
|
||||
fullPage: true,
|
||||
interaction: [click('a:text-is("Activate an account with a code")')],
|
||||
ready: ready("#activation-code:enabled"),
|
||||
},
|
||||
{
|
||||
id: "limited",
|
||||
label:
|
||||
"No ordinary session or device approval before Passkey completion",
|
||||
fullPage: true,
|
||||
interaction: [
|
||||
fill("#activation-handle", "alice"),
|
||||
fill("#activation-code", "synthetic-input-only"),
|
||||
click('button:text-is("Register Passkey and activate")'),
|
||||
],
|
||||
ready: ready('button:text-is("Cancel registration")'),
|
||||
},
|
||||
{
|
||||
id: "cancelled",
|
||||
label: "Activation cancellation and login recovery entry",
|
||||
fullPage: true,
|
||||
interaction: [click('button:text-is("Cancel registration")')],
|
||||
ready: ready('[role="alert"]'),
|
||||
},
|
||||
],
|
||||
),
|
||||
];
|
||||
await Deno.mkdir(`${root}/target/web-ux`, { recursive: true });
|
||||
for (const colorScheme of ["light", "dark"] as const) {
|
||||
for (const width of [1440, 768, 390]) {
|
||||
const scenario: Scenario = {
|
||||
schemaVersion: 1,
|
||||
id: `auth-${colorScheme}-${width}`,
|
||||
title:
|
||||
"Standalone authentication synthetic projection on production build",
|
||||
baseUrl: "http://127.0.0.1:15392",
|
||||
colorScheme,
|
||||
reducedMotion: "reduce",
|
||||
personas: [{
|
||||
id: "fixture",
|
||||
label: "Synthetic HTTP/WebAuthn only",
|
||||
auth: { kind: "anonymous" },
|
||||
}],
|
||||
viewports: [{ width, height: 900, label: `${width}` }],
|
||||
routes,
|
||||
redact: {
|
||||
// 初期撮影後にもfillする。空の入力まで恒久的に隠すと次の操作が不能になる。
|
||||
// 合成codeはsubmit直後に破棄され、pendingのdisabled入力だけを隠す。
|
||||
selectors: ["#activation-code:disabled", ".secret"],
|
||||
text: ["synthetic-input-only"],
|
||||
},
|
||||
processes: [{
|
||||
id: "auth-projection",
|
||||
command: "deno",
|
||||
cwd: root,
|
||||
args: [
|
||||
"run",
|
||||
"--config",
|
||||
"tools/web-ux/deno.json",
|
||||
"--allow-read",
|
||||
"--allow-net",
|
||||
"web/workspace/test/auth-browser/fixture-server.ts",
|
||||
"15392",
|
||||
"web/workspace/build",
|
||||
],
|
||||
readyUrl: "http://127.0.0.1:15392/health",
|
||||
}],
|
||||
};
|
||||
const scenarioPath =
|
||||
`${root}/target/web-ux/auth-${phase}-${colorScheme}-${width}.json`;
|
||||
await Deno.writeTextFile(scenarioPath, JSON.stringify(scenario, null, 2));
|
||||
const result = await capture({
|
||||
scenarioPath,
|
||||
outputDirectory: `${root}/target/web-ux`,
|
||||
runId: `auth-${phase}-${colorScheme}-${width}`,
|
||||
});
|
||||
console.log(
|
||||
JSON.stringify({
|
||||
colorScheme,
|
||||
width,
|
||||
status: result.status,
|
||||
captures: result.captures.length,
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,750 @@
|
||||
/// <reference lib="dom" />
|
||||
// 本番build/shell/router/clientと合成HTTP境界の検証。実Server/SQLite/SoftPasskey E2Eとは別。
|
||||
import { assertEquals } from "@std/assert";
|
||||
import { resolve } from "@std/path";
|
||||
import { chromium, type Page } from "playwright";
|
||||
import { creationResponse } from "../../src/lib/workspace/api/workspace-catalog.test-fixtures.ts";
|
||||
import {
|
||||
startOwnedProcesses,
|
||||
stopOwnedProcesses,
|
||||
} from "../../../../tools/web-ux/src/processes.ts";
|
||||
const root = resolve(import.meta.dirname!, "../../../..");
|
||||
const origin = "http://127.0.0.1:15411";
|
||||
const ready = (page: Page, name: string) =>
|
||||
page.getByRole("link", { name, exact: true }).waitFor();
|
||||
Deno.test("public entry isolates Workspace authority and authenticated dashboard supports explicit selection", async (t) => {
|
||||
const processes = await startOwnedProcesses(
|
||||
[
|
||||
{
|
||||
id: "workspace-fixture",
|
||||
command: "deno",
|
||||
cwd: root,
|
||||
args: [
|
||||
"run",
|
||||
"--allow-read",
|
||||
"--allow-net",
|
||||
"tools/web-ux/browser-tests/workspace_home_fixture_server.ts",
|
||||
"15412",
|
||||
"web/workspace/build",
|
||||
],
|
||||
readyUrl: "http://127.0.0.1:15412/health",
|
||||
},
|
||||
{
|
||||
id: "entry-fixture",
|
||||
command: "deno",
|
||||
cwd: root,
|
||||
args: [
|
||||
"run",
|
||||
"--config",
|
||||
"tools/web-ux/deno.json",
|
||||
"--allow-read",
|
||||
"--allow-net",
|
||||
"web/workspace/test/auth-browser/fixture-server.ts",
|
||||
"15411",
|
||||
"web/workspace/build",
|
||||
"http://127.0.0.1:15412",
|
||||
],
|
||||
readyUrl: `${origin}/health`,
|
||||
},
|
||||
],
|
||||
`${root}/tools/web-ux/scenarios/anonymous-entry.json`,
|
||||
`${root}/target/web-ux/entry-browser/processes`,
|
||||
[],
|
||||
);
|
||||
try {
|
||||
const browser = await chromium.launch();
|
||||
try {
|
||||
for (const colorScheme of ["light", "dark"] as const) {
|
||||
for (const width of [1440, 768, 390]) {
|
||||
const step = async (
|
||||
name: string,
|
||||
body: (page: Page) => Promise<void>,
|
||||
) =>
|
||||
await t.step(`${colorScheme}/${width}: ${name}`, async () => {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height: 900 },
|
||||
colorScheme,
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
const errors: string[] = [];
|
||||
page.on("pageerror", (error) => errors.push(error.message));
|
||||
await body(page);
|
||||
assertEquals(
|
||||
await page.evaluate(() =>
|
||||
document.documentElement.scrollWidth > innerWidth
|
||||
),
|
||||
false,
|
||||
);
|
||||
assertEquals(errors, []);
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
});
|
||||
for (
|
||||
const mode of [
|
||||
"anonymous",
|
||||
"policy-disabled",
|
||||
"policy-error",
|
||||
"policy-loading",
|
||||
]
|
||||
) {
|
||||
await step(
|
||||
`public/login policy ${mode}, no catalog calls even if unavailable`,
|
||||
async (page) => {
|
||||
let workspaceCalls = 0;
|
||||
await page.route("**/api/workspaces*", (route) => {
|
||||
workspaceCalls++;
|
||||
return route.fulfill({ status: 503 });
|
||||
});
|
||||
await page.goto(`${origin}/?fixture=${mode}`);
|
||||
await page.getByRole("heading", { name: "Yoi", exact: true })
|
||||
.waitFor();
|
||||
await ready(page, "Log in");
|
||||
if (mode === "anonymous") {
|
||||
await ready(page, "Create a new account");
|
||||
assertEquals(
|
||||
await page.getByRole("link", {
|
||||
name: "Create a new account",
|
||||
}).getAttribute("href"),
|
||||
"/register?returnTo=%2Fdashboard",
|
||||
);
|
||||
} else {
|
||||
await page.getByText(
|
||||
mode === "policy-disabled"
|
||||
? /registration is disabled/
|
||||
: mode === "policy-error"
|
||||
? /availability is unknown/
|
||||
: /Checking registration/,
|
||||
).waitFor();
|
||||
assertEquals(
|
||||
await page.getByRole("link", {
|
||||
name: "Create a new account",
|
||||
}).count(),
|
||||
0,
|
||||
);
|
||||
}
|
||||
await page.getByRole("link", { name: "Log in", exact: true })
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Log in with passkey" })
|
||||
.waitFor();
|
||||
if (mode === "policy-disabled") {
|
||||
await page.getByText(/registration is disabled/).waitFor();
|
||||
assertEquals(
|
||||
await page.getByRole("link", {
|
||||
name: "Create a new account",
|
||||
}).count(),
|
||||
0,
|
||||
);
|
||||
}
|
||||
assertEquals(workspaceCalls, 0);
|
||||
assertEquals(
|
||||
await page.locator(".workspace-catalog-alert").count(),
|
||||
0,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
await step(
|
||||
"Workspace notifications do not cross logout into the public entry",
|
||||
async (page) => {
|
||||
page.setDefaultTimeout(5000);
|
||||
await page.routeWebSocket(/\/protocol\/ws/, (socket) => {
|
||||
socket.onMessage((message) => {
|
||||
const frame = JSON.parse(String(message));
|
||||
if (frame.message?.method !== "subscribe_events") return;
|
||||
socket.send(JSON.stringify({
|
||||
protocol_version: 2,
|
||||
frame: "response",
|
||||
message: {
|
||||
result: "subscribed",
|
||||
payload: {
|
||||
request_id: frame.message.params.request_id,
|
||||
subscription_id: "fixture-private-alert",
|
||||
selector: { topic: "workspace_workers" },
|
||||
snapshot: {
|
||||
topic: "workers",
|
||||
data: {
|
||||
workers: [{
|
||||
worker_id: "private-worker",
|
||||
runtime_id: "fixture-runtime",
|
||||
resource_key: "W-1",
|
||||
availability: "observed",
|
||||
state: "idle",
|
||||
has_running_internal_workers: false,
|
||||
display_name: "Private Workspace Orion",
|
||||
workdir_attachments: [],
|
||||
}],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}));
|
||||
});
|
||||
});
|
||||
await page.route(
|
||||
"**/api/w/home-owner/runtimes/fixture-runtime/workers/private-worker/stop",
|
||||
(route) =>
|
||||
route.fulfill({
|
||||
json: {
|
||||
state: "accepted",
|
||||
runtime_id: "fixture-runtime",
|
||||
worker_id: "private-worker",
|
||||
diagnostics: [],
|
||||
},
|
||||
}),
|
||||
);
|
||||
await page.goto(`${origin}/w/home-owner?fixture=workspace-one`);
|
||||
if (width === 390) {
|
||||
await page.getByRole("button", { name: "Show sidebar" })
|
||||
.click();
|
||||
}
|
||||
const actions = page.getByRole("button", {
|
||||
name: "Actions for Private Workspace Orion",
|
||||
});
|
||||
await actions.focus();
|
||||
await actions.press("Enter");
|
||||
await page.getByRole("menuitem", { name: "Stop", exact: true })
|
||||
.click();
|
||||
await page.getByText("Private Workspace Orion stopped", {
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
if (width === 390) {
|
||||
const hideSidebar = page.getByRole("button", {
|
||||
name: "Hide sidebar",
|
||||
});
|
||||
await hideSidebar.focus();
|
||||
await hideSidebar.press("Enter");
|
||||
}
|
||||
const accountLink = page.getByRole("link", {
|
||||
name: "Open Account",
|
||||
exact: true,
|
||||
});
|
||||
await accountLink.focus();
|
||||
await accountLink.press("Enter");
|
||||
await page.getByRole("button", { name: "Log out", exact: true })
|
||||
.waitFor();
|
||||
const retainedCounts = [
|
||||
await page.locator(".workspace-alert").count(),
|
||||
];
|
||||
await page.getByRole("button", { name: "Log out", exact: true })
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Log in with passkey" })
|
||||
.waitFor();
|
||||
retainedCounts.push(
|
||||
await page.locator(".workspace-alert").count(),
|
||||
);
|
||||
await page.getByRole("link", {
|
||||
name: "Dashboard / Workspaces",
|
||||
exact: true,
|
||||
}).click();
|
||||
await ready(page, "Log in");
|
||||
retainedCounts.push(
|
||||
await page.locator(".workspace-alert").count(),
|
||||
);
|
||||
await page.getByRole("link", { name: "Log in", exact: true })
|
||||
.click();
|
||||
await page.getByRole("button", { name: "Log in with passkey" })
|
||||
.waitFor();
|
||||
retainedCounts.push(
|
||||
await page.locator(".workspace-alert").count(),
|
||||
);
|
||||
await page.getByRole("link", { name: "Yoi home", exact: true })
|
||||
.click();
|
||||
await ready(page, "Log in");
|
||||
const folder =
|
||||
`${root}/target/web-ux/entry-browser/private-alerts-${colorScheme}-${width}`;
|
||||
await Deno.mkdir(folder, { recursive: true });
|
||||
const bytes = await page.screenshot({
|
||||
path: folder + "/viewport.png",
|
||||
fullPage: true,
|
||||
});
|
||||
const source = await new Deno.Command("git", {
|
||||
cwd: root,
|
||||
args: ["rev-parse", "HEAD"],
|
||||
stdout: "piped",
|
||||
}).output();
|
||||
const dirty = await new Deno.Command("git", {
|
||||
cwd: root,
|
||||
args: ["status", "--porcelain"],
|
||||
stdout: "piped",
|
||||
}).output();
|
||||
const hash = Array.from(
|
||||
new Uint8Array(
|
||||
await crypto.subtle.digest("SHA-256", Uint8Array.from(bytes)),
|
||||
),
|
||||
)
|
||||
.map((v) => v.toString(16).padStart(2, "0")).join("");
|
||||
await Deno.writeTextFile(
|
||||
folder + "/review-context.json",
|
||||
JSON.stringify(
|
||||
{
|
||||
scenario:
|
||||
"Workspace Stop notification → Account logout → dashboard/login → public home (SPA)",
|
||||
boundary:
|
||||
"synthetic HTTP and Worker subscription; production UI/router/store",
|
||||
path: new URL(page.url()).pathname,
|
||||
source: {
|
||||
commitSha: new TextDecoder().decode(source.stdout).trim(),
|
||||
dirty:
|
||||
new TextDecoder().decode(dirty.stdout).trim() !== "",
|
||||
},
|
||||
screenshot: { path: "viewport.png", sha256: hash },
|
||||
standaloneNotificationCounts: retainedCounts,
|
||||
colorScheme,
|
||||
viewport: { width, height: 900 },
|
||||
visibleNotifications: await page.locator(".workspace-alert")
|
||||
.allTextContents(),
|
||||
accessibility: await page.locator("body").ariaSnapshot(),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
assertEquals(retainedCounts, [0, 0, 0, 0]);
|
||||
assertEquals(await page.locator(".workspace-alert").count(), 0);
|
||||
let release!: () => void;
|
||||
const recheck = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
await page.route("**/api/auth/whoami", async (route) => {
|
||||
await recheck;
|
||||
await route.fulfill({
|
||||
status: 503,
|
||||
body: "synthetic unavailable session",
|
||||
});
|
||||
});
|
||||
await page.evaluate(() =>
|
||||
window.dispatchEvent(new Event("focus"))
|
||||
);
|
||||
await page.getByText("Loading session…").waitFor();
|
||||
assertEquals(await page.locator(".workspace-alert").count(), 0);
|
||||
release();
|
||||
await page.getByText(/Login status is unknown/).waitFor();
|
||||
assertEquals(await page.locator(".workspace-alert").count(), 0);
|
||||
assertEquals(
|
||||
await page.getByText("Private Workspace Orion stopped", {
|
||||
exact: true,
|
||||
}).count(),
|
||||
0,
|
||||
);
|
||||
},
|
||||
);
|
||||
for (const mode of ["session-error", "session-loading"]) {
|
||||
await step(
|
||||
`public ${mode} is not anonymous or authenticated`,
|
||||
async (page) => {
|
||||
await page.goto(`${origin}/?fixture=${mode}`);
|
||||
await page.getByText(
|
||||
mode === "session-error"
|
||||
? /Login status is unknown/
|
||||
: "Loading session…",
|
||||
).waitFor();
|
||||
assertEquals(
|
||||
await page.getByRole("link", { name: "Log in", exact: true })
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
assertEquals(
|
||||
await page.getByRole("link", { name: "Create a new account" })
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
assertEquals(
|
||||
await page.getByRole("link", { name: "Open dashboard" })
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
await step(
|
||||
"session retry and focus/session change remove stale actor/admin links",
|
||||
async (page) => {
|
||||
await page.goto(`${origin}/?fixture=session-retry`);
|
||||
await page.getByRole("button", { name: "Retry session" }).click();
|
||||
await ready(page, "Log in");
|
||||
await page.goto(`${origin}/?fixture=admin`);
|
||||
await ready(page, "Hosting settings");
|
||||
await ready(page, "Open dashboard");
|
||||
await page.request.post(`${origin}/api/auth/logout`);
|
||||
await page.evaluate(() =>
|
||||
window.dispatchEvent(new Event("yoi:browser-session-changed"))
|
||||
);
|
||||
await ready(page, "Log in");
|
||||
assertEquals(
|
||||
await page.getByRole("link", { name: "Hosting settings" })
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
assertEquals(
|
||||
await page.getByRole("link", { name: "Open dashboard" })
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
},
|
||||
);
|
||||
for (
|
||||
const mode of [
|
||||
"account",
|
||||
"workspace-one",
|
||||
"workspace-many",
|
||||
"admin",
|
||||
]
|
||||
) {
|
||||
await step(
|
||||
`actor ${mode}: dashboard and explicit Workspace navigation`,
|
||||
async (page) => {
|
||||
await page.goto(`${origin}/?fixture=${mode}`);
|
||||
await ready(page, "Open dashboard");
|
||||
await ready(page, "Account");
|
||||
if (mode === "admin") await ready(page, "Hosting settings");
|
||||
else {assertEquals(
|
||||
await page.getByRole("link", { name: "Hosting settings" })
|
||||
.count(),
|
||||
0,
|
||||
);}
|
||||
await page.getByRole("link", { name: "Open dashboard" })
|
||||
.click();
|
||||
await page.getByRole("heading", {
|
||||
name: "Dashboard",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
if (mode === "workspace-one" || mode === "workspace-many") {
|
||||
const links = page.locator(".workspace-catalog-card");
|
||||
await links.first().waitFor();
|
||||
assertEquals(
|
||||
await links.count(),
|
||||
mode === "workspace-one" ? 1 : 3,
|
||||
);
|
||||
assertEquals(new URL(page.url()).pathname, "/dashboard");
|
||||
await links.first().click();
|
||||
await page.waitForURL("**/w/home-empty");
|
||||
await page.locator(".workspace-home").waitFor();
|
||||
// canonical direct link remains independently reopenable, including another Workspace.
|
||||
await page.goto(`${origin}/w/home-member`);
|
||||
await page.locator(".workspace-home").waitFor();
|
||||
assertEquals(new URL(page.url()).pathname, "/w/home-member");
|
||||
} else {
|
||||
await page.getByText("No accessible Workspaces", {
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
await page.locator("summary#workspace-create-title").click();
|
||||
await page.getByLabel("Workspace display name").fill(
|
||||
"New Workspace",
|
||||
);
|
||||
const submitted = page.waitForRequest((request) =>
|
||||
new URL(request.url()).pathname === "/api/workspaces" &&
|
||||
request.method() === "POST"
|
||||
);
|
||||
await page.getByRole("button", {
|
||||
name: "Create Workspace",
|
||||
exact: true,
|
||||
}).click();
|
||||
const keys = Object.keys((await submitted).postDataJSON())
|
||||
.sort();
|
||||
assertEquals(keys, [
|
||||
"display_name",
|
||||
"operation_key",
|
||||
"repository",
|
||||
]);
|
||||
await page.waitForURL("**/w/home-empty");
|
||||
await page.locator(".workspace-home").waitFor();
|
||||
}
|
||||
},
|
||||
);
|
||||
}
|
||||
for (
|
||||
const mode of [
|
||||
"workspace-unauthorized",
|
||||
"workspace-error",
|
||||
"workspace-loading",
|
||||
]
|
||||
) {
|
||||
await step(
|
||||
`catalog ${mode} does not erase account or imply empty`,
|
||||
async (page) => {
|
||||
await page.goto(`${origin}/dashboard?fixture=${mode}`);
|
||||
await ready(page, "Account");
|
||||
await page.getByText(
|
||||
mode === "workspace-loading"
|
||||
? "Loading Workspaces…"
|
||||
: /Workspaces could not be loaded/,
|
||||
).waitFor();
|
||||
assertEquals(
|
||||
await page.getByText("No accessible Workspaces", {
|
||||
exact: true,
|
||||
}).count(),
|
||||
0,
|
||||
);
|
||||
assertEquals(new URL(page.url()).pathname, "/dashboard");
|
||||
await page.getByRole("link", { name: "Yoi", exact: true })
|
||||
.click();
|
||||
await ready(page, "Open dashboard");
|
||||
assertEquals(await page.getByRole("alert").count(), 0);
|
||||
},
|
||||
);
|
||||
}
|
||||
await step(
|
||||
"pending creation survives same-actor focus without a second POST",
|
||||
async (page) => {
|
||||
let release!: () => void;
|
||||
const held = new Promise<void>((resolve) => {
|
||||
release = resolve;
|
||||
});
|
||||
let started!: () => void;
|
||||
const submitted = new Promise<void>((resolve) => {
|
||||
started = resolve;
|
||||
});
|
||||
let posts = 0;
|
||||
await page.route("**/api/workspaces", async (route) => {
|
||||
if (route.request().method() !== "POST") {
|
||||
return route.continue();
|
||||
}
|
||||
posts++;
|
||||
started();
|
||||
await held;
|
||||
await route.fulfill({ json: creationResponse() });
|
||||
});
|
||||
await page.goto(
|
||||
`${origin}/dashboard?fixture=account#workspace-create-title`,
|
||||
);
|
||||
await page.getByLabel("Workspace display name").fill(
|
||||
"Pending draft",
|
||||
);
|
||||
await page.getByRole("button", {
|
||||
name: "Create Workspace",
|
||||
exact: true,
|
||||
}).click();
|
||||
await submitted;
|
||||
const checked = page.waitForResponse((response) =>
|
||||
new URL(response.url()).pathname === "/api/auth/whoami"
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.dispatchEvent(new Event("focus"))
|
||||
);
|
||||
await checked;
|
||||
await page.getByRole("button", { name: "Creating…", exact: true })
|
||||
.waitFor();
|
||||
assertEquals(
|
||||
await page.getByRole("button", {
|
||||
name: "Creating…",
|
||||
exact: true,
|
||||
}).isDisabled(),
|
||||
true,
|
||||
);
|
||||
assertEquals(
|
||||
await page.getByLabel("Workspace display name").inputValue(),
|
||||
"Pending draft",
|
||||
);
|
||||
assertEquals(posts, 1);
|
||||
release();
|
||||
await page.waitForURL("**/w/home-empty");
|
||||
await page.locator(".workspace-home").waitFor();
|
||||
assertEquals(posts, 1);
|
||||
},
|
||||
);
|
||||
await step(
|
||||
"success during unknown session is retained for explicit same-actor recovery",
|
||||
async (page) => {
|
||||
let releaseCreate!: () => void;
|
||||
const createGate = new Promise<void>((resolve) => {
|
||||
releaseCreate = resolve;
|
||||
});
|
||||
let started!: () => void;
|
||||
const submitted = new Promise<void>((resolve) => {
|
||||
started = resolve;
|
||||
});
|
||||
await page.route("**/api/workspaces", async (route) => {
|
||||
if (route.request().method() !== "POST") {
|
||||
return route.continue();
|
||||
}
|
||||
started();
|
||||
await createGate;
|
||||
await route.fulfill({ json: creationResponse() });
|
||||
});
|
||||
const document = await page.goto(
|
||||
`${origin}/dashboard?fixture=account#workspace-create-title`,
|
||||
);
|
||||
await page.getByLabel("Workspace display name").fill(
|
||||
"Confirmed result during recheck",
|
||||
);
|
||||
await page.getByRole("button", {
|
||||
name: "Create Workspace",
|
||||
exact: true,
|
||||
}).click();
|
||||
await submitted;
|
||||
let releaseSession!: () => void;
|
||||
const sessionGate = new Promise<void>((resolve) => {
|
||||
releaseSession = resolve;
|
||||
});
|
||||
await page.route("**/api/auth/whoami", async (route) => {
|
||||
await sessionGate;
|
||||
await route.fulfill({
|
||||
json: {
|
||||
actor: {
|
||||
user_id: "fixture-user",
|
||||
account_id: "fixture-account",
|
||||
handle: "alice",
|
||||
display_name: "Alice",
|
||||
auth_method: "browser_session",
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
await page.evaluate(() =>
|
||||
window.dispatchEvent(new Event("focus"))
|
||||
);
|
||||
await page.getByText("Loading session…", { exact: true })
|
||||
.waitFor();
|
||||
const completed = page.waitForResponse((response) =>
|
||||
response.request().method() === "POST" &&
|
||||
new URL(response.url()).pathname === "/api/workspaces"
|
||||
);
|
||||
releaseCreate();
|
||||
await (await completed).finished();
|
||||
// HTTP完了後のbrowser task/render境界まで進め、session不明中には遷移しないことを観測する。
|
||||
await page.evaluate(() =>
|
||||
new Promise<void>((resolve) =>
|
||||
requestAnimationFrame(() => resolve())
|
||||
)
|
||||
);
|
||||
assertEquals(new URL(page.url()).pathname, "/dashboard");
|
||||
assertEquals(
|
||||
await page.getByLabel("Workspace display name").count(),
|
||||
0,
|
||||
);
|
||||
releaseSession();
|
||||
const link = page.getByRole("link", {
|
||||
name: "Open created Workspace",
|
||||
exact: true,
|
||||
});
|
||||
await link.waitFor();
|
||||
assertEquals(await link.getAttribute("href"), "/w/home-empty");
|
||||
assertEquals(
|
||||
await page.getByRole("button", {
|
||||
name: "Create Workspace",
|
||||
exact: true,
|
||||
}).count(),
|
||||
0,
|
||||
);
|
||||
const folder =
|
||||
`${root}/target/web-ux/entry-browser/confirmed-${colorScheme}-${width}`;
|
||||
await Deno.mkdir(folder, { recursive: true });
|
||||
const bytes = await page.screenshot({
|
||||
path: folder + "/viewport.png",
|
||||
fullPage: true,
|
||||
});
|
||||
const hash = Array.from(
|
||||
new Uint8Array(
|
||||
await crypto.subtle.digest("SHA-256", Uint8Array.from(bytes)),
|
||||
),
|
||||
).map((v) => v.toString(16).padStart(2, "0")).join("");
|
||||
const source = await new Deno.Command("git", {
|
||||
cwd: root,
|
||||
args: ["rev-parse", "HEAD"],
|
||||
stdout: "piped",
|
||||
}).output();
|
||||
const dirty = await new Deno.Command("git", {
|
||||
cwd: root,
|
||||
args: ["status", "--porcelain"],
|
||||
stdout: "piped",
|
||||
}).output();
|
||||
await Deno.writeTextFile(
|
||||
folder + "/accessibility.txt",
|
||||
await page.locator("body").ariaSnapshot(),
|
||||
);
|
||||
await Deno.writeTextFile(
|
||||
folder + "/review-context.json",
|
||||
JSON.stringify(
|
||||
{
|
||||
scenario:
|
||||
"production-browser confirmed result after session recheck",
|
||||
boundary: "synthetic HTTP; not real Server authentication",
|
||||
source: {
|
||||
commitSha: new TextDecoder().decode(source.stdout).trim(),
|
||||
dirty:
|
||||
new TextDecoder().decode(dirty.stdout).trim() !== "",
|
||||
},
|
||||
document: {
|
||||
path: new URL(page.url()).pathname,
|
||||
status: document?.status(),
|
||||
},
|
||||
colorScheme,
|
||||
viewport: { width, height: 900 },
|
||||
screenshot: { path: "viewport.png", sha256: hash },
|
||||
visibleAlerts: await page.getByRole("alert")
|
||||
.allTextContents(),
|
||||
accessibility: "accessibility.txt",
|
||||
expectedHttpErrors: [],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
},
|
||||
);
|
||||
for (const mode of ["creation-denied", "creation-error"]) {
|
||||
await step(
|
||||
`explicit creation ${mode} retains input and exact retry operation`,
|
||||
async (page) => {
|
||||
await page.goto(
|
||||
`${origin}/dashboard?fixture=${mode}#workspace-create-title`,
|
||||
);
|
||||
await page.getByLabel("Workspace display name").fill(
|
||||
"New Workspace",
|
||||
);
|
||||
const bodies: unknown[] = [];
|
||||
page.on("request", (request) => {
|
||||
if (request.method() === "POST") {
|
||||
bodies.push(request.postDataJSON());
|
||||
}
|
||||
});
|
||||
await page.getByRole("button", {
|
||||
name: "Create Workspace",
|
||||
exact: true,
|
||||
}).click();
|
||||
await page.getByRole("alert").waitFor();
|
||||
const rechecked = page.waitForResponse((response) =>
|
||||
new URL(response.url()).pathname === "/api/auth/whoami"
|
||||
);
|
||||
await page.evaluate(() =>
|
||||
window.dispatchEvent(new Event("focus"))
|
||||
);
|
||||
await rechecked;
|
||||
await page.getByRole("button", {
|
||||
name: "Retry creation",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
assertEquals(
|
||||
await page.getByLabel("Workspace display name").inputValue(),
|
||||
"New Workspace",
|
||||
);
|
||||
await page.getByRole("button", {
|
||||
name: "Retry creation",
|
||||
exact: true,
|
||||
}).click();
|
||||
await page.getByRole("button", {
|
||||
name: "Retry creation",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
assertEquals(bodies.length, 2);
|
||||
assertEquals(bodies[0], bodies[1]);
|
||||
assertEquals(
|
||||
await page.getByLabel("Workspace display name").inputValue(),
|
||||
"New Workspace",
|
||||
);
|
||||
assertEquals(new URL(page.url()).pathname, "/dashboard");
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await browser.close();
|
||||
}
|
||||
} finally {
|
||||
assertEquals(await stopOwnedProcesses(processes), []);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,253 @@
|
||||
// 本番build/client/shellだけを検証する合成HTTP/WebAuthn境界。
|
||||
// 実認証・署名・cookie安全性・policyの永続化は検証しない。本文/credentialは記録しない。
|
||||
import { extname, join, resolve } from "@std/path";
|
||||
import { setupWebAuthnProjection } from "../hosting-browser/setup-projection.ts";
|
||||
|
||||
import {
|
||||
createdWorkspace,
|
||||
creationResponse,
|
||||
} from "../../src/lib/workspace/api/workspace-catalog.test-fixtures.ts";
|
||||
|
||||
const [port, build, workspaceFixture] = Deno.args;
|
||||
if (!port || !build) throw new Error("Expected port and production build root");
|
||||
const root = resolve(build);
|
||||
const user = {
|
||||
user_id: "fixture-user",
|
||||
account_id: "fixture-account",
|
||||
handle: "alice",
|
||||
display_name: "Alice",
|
||||
};
|
||||
type State = {
|
||||
mode: string;
|
||||
signedIn: boolean;
|
||||
whoamiCalls: number;
|
||||
selfRegistrationEnabled: boolean;
|
||||
};
|
||||
const sessions = new Map<string, State>();
|
||||
const mime: Record<string, string> = {
|
||||
".html": "text/html",
|
||||
".js": "text/javascript",
|
||||
".css": "text/css",
|
||||
".json": "application/json",
|
||||
".svg": "image/svg+xml",
|
||||
".woff2": "font/woff2",
|
||||
".png": "image/png",
|
||||
".ico": "image/x-icon",
|
||||
};
|
||||
const options = (login: boolean) => ({
|
||||
challenge_id: "synthetic-challenge-only",
|
||||
public_key: login ? { challenge: "dWk" } : {
|
||||
challenge: "dWk",
|
||||
rp: { name: "UI projection" },
|
||||
user: { id: "dWk", name: "alice", displayName: "Alice" },
|
||||
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
|
||||
},
|
||||
});
|
||||
|
||||
Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
const url = new URL(request.url);
|
||||
const path = url.pathname;
|
||||
if (path === "/health") return new Response("ok");
|
||||
const selected = url.searchParams.get("fixture");
|
||||
let id = request.headers.get("cookie")?.match(/auth-fixture=([\w-]+)/)?.[1];
|
||||
// routeごとに独立。クライアントgoto後も同じ合成sessionをwhoamiへ投影する。
|
||||
if (selected || !id || !sessions.has(id)) {
|
||||
id = crypto.randomUUID();
|
||||
sessions.set(id, {
|
||||
mode: selected ?? "anonymous",
|
||||
signedIn: selected === "account" ||
|
||||
selected?.startsWith("workspace-") === true || selected === "admin" ||
|
||||
selected?.startsWith("creation-") === true ||
|
||||
selected?.startsWith("addition-") === true,
|
||||
whoamiCalls: 0,
|
||||
selfRegistrationEnabled: selected !== "policy-disabled",
|
||||
});
|
||||
}
|
||||
const state = sessions.get(id)!;
|
||||
const { mode } = state;
|
||||
if (path === "/__ui_fixture/webauthn.js") {
|
||||
let extra = "";
|
||||
if (mode.endsWith("-cancel")) {
|
||||
extra =
|
||||
`for (const method of ['get','create']) Object.defineProperty(navigator.credentials,method,{configurable:true,value:async()=>{throw new DOMException('Passkey cancelled','NotAllowedError')}});`;
|
||||
} else if (mode.endsWith("-error")) {
|
||||
extra =
|
||||
`for (const method of ['get','create']) Object.defineProperty(navigator.credentials,method,{configurable:true,value:async()=>{throw new DOMException('Synthetic authenticator failure','UnknownError')}});`;
|
||||
} else if (mode.endsWith("-pending")) {
|
||||
extra =
|
||||
`for (const method of ['get','create']) { const original=navigator.credentials[method].bind(navigator.credentials); Object.defineProperty(navigator.credentials,method,{configurable:true,value:async()=>{ await new Promise(resolve=>{window.__authFixtureRelease=resolve}); return original(); }}); }`;
|
||||
}
|
||||
return new Response(setupWebAuthnProjection + extra, {
|
||||
headers: {
|
||||
"content-type": "text/javascript",
|
||||
"cache-control": "no-store",
|
||||
},
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/whoami") {
|
||||
state.whoamiCalls++;
|
||||
if (mode === "session-loading") {
|
||||
return await new Promise<Response>(() => {});
|
||||
}
|
||||
if (
|
||||
mode === "session-error" ||
|
||||
(mode === "session-retry" && state.whoamiCalls === 1)
|
||||
) {
|
||||
return new Response(null, { status: 503 });
|
||||
}
|
||||
if (mode === "session-malformed") {
|
||||
return Response.json({ actor: { handle: "untrusted" } });
|
||||
}
|
||||
return Response.json({
|
||||
actor: state.signedIn
|
||||
? { ...user, auth_method: "browser_session" }
|
||||
: null,
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/registration-policy") {
|
||||
if (mode === "policy-loading") return await new Promise<Response>(() => {});
|
||||
if (mode === "policy-error") return new Response(null, { status: 503 });
|
||||
return Response.json({
|
||||
self_registration_enabled: state.selfRegistrationEnabled,
|
||||
});
|
||||
}
|
||||
if (path === "/api/hosting/access") {
|
||||
return Response.json({ is_admin: mode === "admin" });
|
||||
}
|
||||
if (path === "/api/workspaces") {
|
||||
if (!state.signedIn || mode === "workspace-unauthorized") {
|
||||
return new Response(null, { status: 401 });
|
||||
}
|
||||
if (mode === "workspace-error") return new Response(null, { status: 503 });
|
||||
if (mode === "workspace-loading") {
|
||||
return await new Promise<Response>(() => {});
|
||||
}
|
||||
if (request.method === "POST") {
|
||||
if (mode === "creation-denied") {
|
||||
return new Response(null, { status: 403 });
|
||||
}
|
||||
if (mode === "creation-error") return new Response(null, { status: 503 });
|
||||
return Response.json(creationResponse());
|
||||
}
|
||||
const ids = mode === "workspace-one"
|
||||
? ["home-empty"]
|
||||
: mode === "workspace-many"
|
||||
? ["home-empty", "home-member", "home-long"]
|
||||
: [];
|
||||
return Response.json(ids.map((workspace_id, i) => ({
|
||||
...createdWorkspace,
|
||||
workspace_id,
|
||||
display_name: i === 0
|
||||
? "New Workspace"
|
||||
: i === 1
|
||||
? "Shared Workspace"
|
||||
: "Workspace with a long name — international documentation and distributed development",
|
||||
})));
|
||||
}
|
||||
if (path === "/api/auth/logout") {
|
||||
state.signedIn = false;
|
||||
return Response.json({ status: "logged_out" });
|
||||
}
|
||||
if (path === "/api/auth/device-login/approve") {
|
||||
if (!state.signedIn) return new Response(null, { status: 401 });
|
||||
if (mode === "device-expired") return new Response(null, { status: 410 });
|
||||
return Response.json({ status: "approved", user });
|
||||
}
|
||||
const passkey = path.match(
|
||||
/^\/api\/auth\/passkeys\/(login|registration|addition)\/(options|complete)$/,
|
||||
);
|
||||
if (passkey) {
|
||||
const [, purpose, stage] = passkey;
|
||||
if (purpose === "addition" && !state.signedIn) {
|
||||
return new Response(null, { status: 401 });
|
||||
}
|
||||
if (purpose === "registration") {
|
||||
if (mode === "policy-stopped" && stage === "complete") {
|
||||
state.selfRegistrationEnabled = false;
|
||||
}
|
||||
if (!state.selfRegistrationEnabled) {
|
||||
return new Response(null, { status: 403 });
|
||||
}
|
||||
}
|
||||
if (mode.endsWith("-http-error") && stage === "options") {
|
||||
return new Response(null, { status: 503 });
|
||||
}
|
||||
if (mode === `${purpose}-expired` && stage === "complete") {
|
||||
if (purpose === "addition") state.signedIn = false;
|
||||
return new Response(null, { status: 401 });
|
||||
}
|
||||
if (stage === "options") return Response.json(options(purpose === "login"));
|
||||
state.signedIn = true;
|
||||
return Response.json({ user });
|
||||
}
|
||||
if (path === "/api/auth/activation/options") {
|
||||
return Response.json(options(false));
|
||||
}
|
||||
if (path === "/api/auth/activation/complete") {
|
||||
state.signedIn = true;
|
||||
return Response.json({ user });
|
||||
}
|
||||
// Workspace画面の既存fixtureを再利用する。認証/登録/作成APIの代わりにはしない。
|
||||
if (workspaceFixture && path.startsWith("/api/w/")) {
|
||||
const target = new URL(path + url.search, workspaceFixture);
|
||||
if (request.headers.get("upgrade") === "websocket") {
|
||||
const { socket, response } = Deno.upgradeWebSocket(request);
|
||||
target.protocol = "ws:";
|
||||
const peer = new WebSocket(target);
|
||||
const pending: string[] = [];
|
||||
socket.onmessage = (event) =>
|
||||
peer.readyState === WebSocket.OPEN
|
||||
? peer.send(event.data)
|
||||
: pending.push(String(event.data));
|
||||
peer.onopen = () => {
|
||||
for (const frame of pending) peer.send(frame);
|
||||
};
|
||||
peer.onmessage = (event) => {
|
||||
if (socket.readyState === WebSocket.OPEN) socket.send(event.data);
|
||||
};
|
||||
socket.onclose = () => peer.close();
|
||||
peer.onclose = () => socket.close();
|
||||
return response;
|
||||
}
|
||||
return await fetch(new Request(target, request));
|
||||
}
|
||||
if (path.startsWith("/api/")) return new Response(null, { status: 404 });
|
||||
let file: string;
|
||||
try {
|
||||
file = join(root, decodeURIComponent(path));
|
||||
} catch {
|
||||
return new Response(null, { status: 400 });
|
||||
}
|
||||
if (!file.startsWith(root + "/")) return new Response(null, { status: 400 });
|
||||
let bytes: Uint8Array;
|
||||
let extension = extname(file);
|
||||
try {
|
||||
bytes = await Deno.readFile(file);
|
||||
} catch (error) {
|
||||
if (
|
||||
!(error instanceof Deno.errors.NotFound) &&
|
||||
!(error instanceof Deno.errors.IsADirectory)
|
||||
) throw error;
|
||||
bytes = await Deno.readFile(join(root, "index.html"));
|
||||
extension = ".html";
|
||||
}
|
||||
if (extension === ".html") {
|
||||
bytes = new TextEncoder().encode(
|
||||
new TextDecoder().decode(bytes).replace(
|
||||
"<head>",
|
||||
'<head><script src="/__ui_fixture/webauthn.js"></script>',
|
||||
),
|
||||
);
|
||||
}
|
||||
const headers = new Headers({
|
||||
"content-type": mime[extension] ?? "application/octet-stream",
|
||||
"cache-control": "no-store",
|
||||
});
|
||||
if (extension === ".html") {
|
||||
headers.set(
|
||||
"set-cookie",
|
||||
`auth-fixture=${id}; Path=/; SameSite=Strict; HttpOnly`,
|
||||
);
|
||||
}
|
||||
return new Response(bytes as Uint8Array<ArrayBuffer>, { headers });
|
||||
});
|
||||
@@ -24,6 +24,7 @@ interface Fixture {
|
||||
workspace_id: string;
|
||||
owner_token: string;
|
||||
second_token: string;
|
||||
foreign_token: string;
|
||||
worker_entry: DriveEntry;
|
||||
}
|
||||
function assert(value: unknown, message: string): asserts value {
|
||||
@@ -164,14 +165,14 @@ try {
|
||||
"revoke must be durably visible",
|
||||
);
|
||||
}
|
||||
globalThis.fetch = authenticatedFetch(fixture.origin, fixture.second_token);
|
||||
globalThis.fetch = authenticatedFetch(fixture.origin, fixture.foreign_token);
|
||||
try {
|
||||
await listDriveGrants(ws);
|
||||
throw new Error("member must not manage grants");
|
||||
throw new Error("foreign Account must not manage grants");
|
||||
} catch (error) {
|
||||
assert(
|
||||
error instanceof DriveGrantError && error.outcome === "not_committed",
|
||||
"member grant read must be a real authorized rejection",
|
||||
"foreign Account grant read must be a real authorized rejection",
|
||||
);
|
||||
}
|
||||
} finally {
|
||||
@@ -318,7 +319,7 @@ const secondRead = await second.readText(ws, updated.entry);
|
||||
equal(
|
||||
secondRead.entry.last_mutation_id,
|
||||
updated.last_mutation_id,
|
||||
"second actor must observe same last_mutation_id",
|
||||
"second owner client must observe same last_mutation_id",
|
||||
);
|
||||
const drafts = ["# actor A draft\n", "# actor B draft\n"];
|
||||
const race = await Promise.allSettled(
|
||||
@@ -335,7 +336,7 @@ const race = await Promise.allSettled(
|
||||
equal(
|
||||
race.filter((result) => result.status === "fulfilled").length,
|
||||
1,
|
||||
"same-last_mutation_id two-actor CAS must have exactly one winner",
|
||||
"same-last_mutation_id two-client CAS must have exactly one winner",
|
||||
);
|
||||
const loser = race.find((result) => result.status === "rejected");
|
||||
assert(
|
||||
@@ -348,7 +349,7 @@ const latest = await owner.readText(ws, updated.entry);
|
||||
equal(
|
||||
latest.text,
|
||||
drafts[winnerIndex],
|
||||
"durable content must be the winning actor's draft",
|
||||
"durable content must be the winning client's draft",
|
||||
);
|
||||
// This API boundary proves no automatic retry/overwrite. UI dirty-draft
|
||||
// retention is deliberately verified by the separate browser fixture.
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
# Hosting UIのブラウザ検証
|
||||
|
||||
production UIと合成API応答の組合せを確認する。Setupだけはbrowser API境界も`setup-projection.ts`で置換する。Backend認可・実token・暗号署名・native WebAuthnを証明する検証ではない。実sessionやsecretは持ち込まず、request headers/bodyも記録しない。
|
||||
|
||||
共有buildは読取り専用とし、画像・診断・一時領域は`target/web-ux`へ分離する。実行入口と境界の理由は各test/capture sourceに置く。自動検査の成功は、画像とreview contextの目視判定を代替しない。
|
||||
@@ -0,0 +1,113 @@
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { resolve } from "@std/path";
|
||||
import type { ReviewContext } from "../../../../tools/web-ux/src/types.ts";
|
||||
const root = resolve(import.meta.dirname!, "../../../..");
|
||||
const output = `${root}/target/web-ux/hosting`;
|
||||
async function sha(bytes: Uint8Array<ArrayBuffer>) {
|
||||
return [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map((value) =>
|
||||
value.toString(16).padStart(2, "0")
|
||||
).join("");
|
||||
}
|
||||
const results = [];
|
||||
for (const theme of ["light", "dark"]) {
|
||||
for (const prefix of ["before", "after", "navigation-before", "navigation-after"]) {
|
||||
const run = `${prefix}-final-${theme}`;
|
||||
const context: ReviewContext = JSON.parse(
|
||||
await Deno.readTextFile(`${output}/${run}/review-context.json`),
|
||||
);
|
||||
assertEquals(context.diagnostics, []);
|
||||
assert(!context.diagnosticSummary.truncated);
|
||||
let screenshots = 0, expectedFailureCaptures = 0;
|
||||
for (const capture of context.captures) {
|
||||
assertEquals(capture.document.status, 200);
|
||||
assert(!capture.errorSummary.truncated);
|
||||
const expectedFailure = ["admin-load-error", "admin-save-error", "account-error"].includes(
|
||||
capture.route.id,
|
||||
);
|
||||
if (expectedFailure) {
|
||||
expectedFailureCaptures++;
|
||||
// Non-OK bodies are cancelled by production client; ERR_ABORTED is the
|
||||
// same deliberately failed response, not an unrelated network failure.
|
||||
assertEquals(capture.errors.length, 3);
|
||||
assertEquals(
|
||||
capture.errors.filter((error) => error.status === 503).length,
|
||||
1,
|
||||
);
|
||||
assertEquals(
|
||||
capture.errors.filter((error) =>
|
||||
error.kind === "console" && error.message.includes("503")
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
assertEquals(
|
||||
capture.errors.filter((error) =>
|
||||
error.kind === "request" && error.message === "net::ERR_ABORTED"
|
||||
).length,
|
||||
1,
|
||||
);
|
||||
} else assertEquals(capture.errors, []);
|
||||
assert(capture.snapshot !== null);
|
||||
const snapshot = await Deno.readTextFile(`${output}/${run}/${capture.snapshot.bundlePath}`);
|
||||
if (["ordinary", "anonymous"].includes(capture.route.id)) {
|
||||
assert(!snapshot.includes('button "Edit registration policy"'));
|
||||
assert(!snapshot.includes('link "Hosting settings"'));
|
||||
}
|
||||
if (capture.route.id === "ordinary-account-off") {
|
||||
assert(snapshot.includes('button "Add passkey to alice"'));
|
||||
assert(!snapshot.includes('link "Hosting settings"'));
|
||||
}
|
||||
for (const screenshot of capture.screenshots) {
|
||||
assertEquals(
|
||||
await sha(await Deno.readFile(`${output}/${run}/${screenshot.bundlePath}`)),
|
||||
screenshot.sha256,
|
||||
);
|
||||
screenshots++;
|
||||
}
|
||||
}
|
||||
assertEquals(expectedFailureCaptures, prefix === "after" ? 9 : 0);
|
||||
results.push({
|
||||
run,
|
||||
captures: context.captures.length,
|
||||
screenshots,
|
||||
expectedFailureCaptures,
|
||||
allDocuments200: true,
|
||||
unexpectedErrors: 0,
|
||||
imageHashesVerified: true,
|
||||
diagnosticTruncation: false,
|
||||
});
|
||||
}
|
||||
}
|
||||
async function inventory(directory: string): Promise<{ path: string; sha256: string }[]> {
|
||||
const files = [];
|
||||
for await (const entry of Deno.readDir(directory)) {
|
||||
const path = `${directory}/${entry.name}`;
|
||||
if (entry.isDirectory) files.push(...await inventory(path));
|
||||
else if (entry.isFile) {
|
||||
files.push({
|
||||
path: path.slice(root.length + 1),
|
||||
sha256: await sha(await Deno.readFile(path)),
|
||||
});
|
||||
}
|
||||
}
|
||||
return files.sort((a, b) => a.path.localeCompare(b.path));
|
||||
}
|
||||
const baselineAssets = await inventory(`${root}/target/web-ux/baseline-source/web/workspace/build`);
|
||||
const currentAssets = await inventory(`${root}/web/workspace/build`);
|
||||
await Deno.writeTextFile(
|
||||
`${output}/evidence-audit.json`,
|
||||
JSON.stringify(
|
||||
{
|
||||
boundary:
|
||||
"Actual production static shells; synthetic HTTP only. No Backend authorization/persistence/WebAuthn evidence.",
|
||||
baselineSource: "git archive 7042ec7d50ca8b03b8034c33e9792f4b6ac9d48d web/workspace",
|
||||
currentSource:
|
||||
"Dirty shared worktree at same HEAD; exact served build bytes identified by inventory. Parent owns source/build provenance and final visual verdict.",
|
||||
results,
|
||||
baselineAssets,
|
||||
currentAssets,
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log(JSON.stringify(results, null, 2));
|
||||
@@ -0,0 +1,142 @@
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { resolve } from "@std/path";
|
||||
import type { ReviewContext } from "../../../../tools/web-ux/src/types.ts";
|
||||
const root = resolve(import.meta.dirname!, "../../../..");
|
||||
const output = `${root}/target/web-ux/hosting`;
|
||||
const synthetic = `ui-projection-${crypto.randomUUID()}`;
|
||||
// 合成値はこの子processのmemory/envに限定する。argv、URL、保存証拠へ出さない。
|
||||
await Deno.mkdir(`${root}/target/web-ux/tmp`, { recursive: true });
|
||||
const audit = [];
|
||||
const includeExisting = Deno.args.includes("--existing");
|
||||
const runLabel = Deno.args.find((arg) => arg.startsWith("--run-label="))?.slice(12) ??
|
||||
String(Date.now());
|
||||
assert(/^[a-z0-9-]+$/.test(runLabel), "run label must be a safe path component");
|
||||
for (const theme of ["light", "dark"]) {
|
||||
for (
|
||||
const kind of includeExisting ? ["setup", "settings"] : ["setup"]
|
||||
) {
|
||||
const run = `${kind === "setup" ? "setup" : "after-setup"}-${runLabel}-${theme}`;
|
||||
const child = await new Deno.Command(Deno.execPath(), {
|
||||
cwd: root,
|
||||
env: { TMPDIR: `${root}/target/web-ux/tmp`, WEB_UX_SYNTHETIC_SETUP_INPUT: synthetic },
|
||||
args: [
|
||||
"run",
|
||||
"--config",
|
||||
"tools/web-ux/deno.json",
|
||||
"--allow-env",
|
||||
"--allow-net",
|
||||
"--allow-read",
|
||||
"--allow-write=target/web-ux",
|
||||
"--allow-run",
|
||||
"--allow-sys",
|
||||
"tools/web-ux/cli.ts",
|
||||
"capture",
|
||||
"--scenario",
|
||||
`tools/web-ux/scenarios/hosting-${kind}-${theme}.json`,
|
||||
"--output",
|
||||
"target/web-ux/hosting",
|
||||
"--run-id",
|
||||
run,
|
||||
],
|
||||
stdout: "inherit",
|
||||
stderr: "inherit",
|
||||
}).output();
|
||||
assertEquals(child.code, 2); // 両scenarioとも意図した拒否・障害表示を含む。
|
||||
const context: ReviewContext = JSON.parse(
|
||||
await Deno.readTextFile(`${output}/${run}/review-context.json`),
|
||||
);
|
||||
assertEquals(context.diagnostics, []);
|
||||
assertEquals(context.status, "completed-with-errors");
|
||||
assertEquals(context.captures.length, kind === "setup" ? 18 : 54);
|
||||
let images = 0;
|
||||
for (const capture of context.captures) {
|
||||
assertEquals(capture.document.status, 200);
|
||||
assertEquals(capture.errorSummary.truncated, false);
|
||||
const rejected = capture.route.id === "setup-reject";
|
||||
const expected = rejected ||
|
||||
["admin-load-error", "admin-save-error", "account-error"].includes(capture.route.id);
|
||||
if (expected) {
|
||||
assert(capture.errors.every((error) =>
|
||||
error.status === (rejected ? 403 : 503) ||
|
||||
(error.kind === "console" && error.message.includes(String(rejected ? 403 : 503))) ||
|
||||
(error.kind === "request" && error.message === "net::ERR_ABORTED")
|
||||
));
|
||||
assert(capture.errors.some((error) => error.status === (rejected ? 403 : 503)));
|
||||
} else assertEquals(capture.errors, []);
|
||||
for (const image of capture.screenshots) {
|
||||
assertEquals(
|
||||
await sha(await Deno.readFile(`${output}/${run}/${image.bundlePath}`)),
|
||||
image.sha256,
|
||||
);
|
||||
images++;
|
||||
}
|
||||
}
|
||||
let scannedTextFiles = 0;
|
||||
for await (const path of files(`${output}/${run}`)) {
|
||||
if (/\.(json|md|html|log)$/.test(path)) {
|
||||
assert(
|
||||
!(await Deno.readTextFile(path)).includes(synthetic),
|
||||
"synthetic input must not appear in artifacts",
|
||||
);
|
||||
scannedTextFiles++;
|
||||
}
|
||||
}
|
||||
audit.push({
|
||||
run,
|
||||
source: context.source,
|
||||
captures: context.captures.length,
|
||||
images,
|
||||
unexpectedErrors: 0,
|
||||
allDocuments200: true,
|
||||
imageHashesVerified: true,
|
||||
syntheticInputAbsent: true,
|
||||
scannedTextFiles,
|
||||
});
|
||||
}
|
||||
}
|
||||
async function sha(bytes: Uint8Array<ArrayBuffer>) {
|
||||
return [...new Uint8Array(await crypto.subtle.digest("SHA-256", bytes))].map((value) =>
|
||||
value.toString(16).padStart(2, "0")
|
||||
).join("");
|
||||
}
|
||||
async function* files(dir: string): AsyncGenerator<string> {
|
||||
for await (const entry of Deno.readDir(dir)) {
|
||||
const path = `${dir}/${entry.name}`;
|
||||
if (entry.isDirectory) yield* files(path);
|
||||
else if (entry.isFile) yield path;
|
||||
}
|
||||
}
|
||||
async function inventory(dir: string) {
|
||||
const result = [];
|
||||
for await (const path of files(dir)) {
|
||||
result.push({
|
||||
path: path.slice(root.length + 1),
|
||||
sha256: await sha(await Deno.readFile(path)),
|
||||
});
|
||||
}
|
||||
return result.sort((a, b) => a.path.localeCompare(b.path));
|
||||
}
|
||||
const diff = await new Deno.Command("git", {
|
||||
cwd: root,
|
||||
args: ["diff", "--binary", "HEAD", "--", "web/workspace/src", "web/workspace/deno.json"],
|
||||
stdout: "piped",
|
||||
stderr: "null",
|
||||
}).output();
|
||||
assert(diff.success);
|
||||
await Deno.writeTextFile(
|
||||
`${output}/setup-evidence-audit.json`,
|
||||
JSON.stringify(
|
||||
{
|
||||
boundary:
|
||||
"Production UI modules/styles unchanged. Setup-only bootstrap replaces navigator.credentials create/get; synthetic API responses. Not native WebAuthn, real token proof, authorization or persistence evidence. No request header/body logging.",
|
||||
audit,
|
||||
currentTrackedWebDiffSha256: await sha(diff.stdout),
|
||||
currentSourceFiles: await inventory(`${root}/web/workspace/src`),
|
||||
currentBuildFiles: await inventory(`${root}/web/workspace/build`),
|
||||
fixtureFiles: await inventory(import.meta.dirname!),
|
||||
},
|
||||
null,
|
||||
2,
|
||||
),
|
||||
);
|
||||
console.log("Capture diagnostics, image hashes and synthetic-input artifact scan PASS.");
|
||||
@@ -0,0 +1,111 @@
|
||||
// UI projection fixture ONLY: actual production assets, synthetic API boundary.
|
||||
// No real sessions/accounts/DB or Backend authorization claims. Cookie is a
|
||||
// public fixture selector, not a credential; it is never saved as auth state.
|
||||
import { extname, join, resolve } from "@std/path";
|
||||
import { setupProjectionResponse } from "./setup-projection.ts";
|
||||
const [port, build] = Deno.args;
|
||||
if (!port || !build) throw new Error("usage: fixture-server.ts <port> <build-root>");
|
||||
const buildRoot = resolve(build);
|
||||
const mime: Record<string, string> = {
|
||||
".html": "text/html",
|
||||
".js": "text/javascript",
|
||||
".css": "text/css",
|
||||
".json": "application/json",
|
||||
".svg": "image/svg+xml",
|
||||
".woff2": "font/woff2",
|
||||
".png": "image/png",
|
||||
};
|
||||
const policies = new Map<string, boolean>();
|
||||
// 各fixture processはscenarioを逐次実行する。setupのBearer-only要求はcookieを
|
||||
// 送らないので、秘密やheadersを読まず直前の合成page条件で応答を選ぶ。
|
||||
let setupMode: string | null = null;
|
||||
Deno.serve({ hostname: "127.0.0.1", port: Number(port) }, async (request) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === "/health") return new Response("ok");
|
||||
const selected = url.searchParams.get("fixture");
|
||||
if (url.pathname === "/hosting/setup" && selected?.startsWith("setup-")) setupMode = selected;
|
||||
const mode = selected ?? request.headers.get("cookie")?.match(/ui-fixture=([\w-]+)/)?.[1] ??
|
||||
(url.pathname.startsWith("/api/auth/passkeys/addition/") ? setupMode : null) ?? "admin-on";
|
||||
if (selected) policies.delete(mode);
|
||||
const setup = setupProjectionResponse(url.pathname, mode, selected !== null);
|
||||
if (setup) return setup;
|
||||
const anonymous = mode.startsWith("anonymous") || mode.startsWith("account-");
|
||||
const ordinary = mode.startsWith("ordinary");
|
||||
if (url.pathname === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
actor: anonymous ? null : {
|
||||
user_id: "fixture-user",
|
||||
account_id: "fixture-account",
|
||||
handle: "alice",
|
||||
display_name: "Alice",
|
||||
auth_method: "browser_session",
|
||||
},
|
||||
});
|
||||
}
|
||||
if (url.pathname === "/api/workspaces") return Response.json([]);
|
||||
if (url.pathname === "/api/hosting/access") {
|
||||
return Response.json({ is_admin: !ordinary && !anonymous });
|
||||
}
|
||||
const policy = () => ({ self_registration_enabled: policies.get(mode) ?? !mode.includes("off") });
|
||||
if (url.pathname === "/api/auth/registration-policy") {
|
||||
if (mode === "account-error") {
|
||||
return new Response("synthetic availability failure", { status: 503 });
|
||||
}
|
||||
if (mode === "account-loading") return new Promise<Response>(() => {});
|
||||
return Response.json(policy());
|
||||
}
|
||||
if (url.pathname === "/api/hosting/settings") {
|
||||
if (anonymous || ordinary) {
|
||||
return new Response("synthetic denied", { status: anonymous ? 401 : 403 });
|
||||
}
|
||||
if (mode === "admin-load-error") {
|
||||
return new Response("synthetic settings failure", { status: 503 });
|
||||
}
|
||||
if (mode === "admin-loading" || (mode === "admin-saving" && request.method === "PUT")) {
|
||||
return new Promise<Response>(() => {});
|
||||
}
|
||||
if (request.method === "PUT") {
|
||||
if (mode === "admin-save-error") {
|
||||
return new Response("synthetic unconfirmed save", { status: 503 });
|
||||
}
|
||||
const data = await request.json();
|
||||
if (typeof data.self_registration_enabled !== "boolean") {
|
||||
return new Response("invalid", { status: 400 });
|
||||
}
|
||||
policies.set(mode, data.self_registration_enabled);
|
||||
}
|
||||
return Response.json(policy());
|
||||
}
|
||||
if (url.pathname.startsWith("/api/")) {
|
||||
return new Response("unexpected fixture API", { status: 404 });
|
||||
}
|
||||
const path = join(buildRoot, decodeURIComponent(url.pathname));
|
||||
if (!path.startsWith(buildRoot + "/")) return new Response("invalid path", { status: 400 });
|
||||
let bytes: Uint8Array;
|
||||
let extension = extname(path);
|
||||
try {
|
||||
bytes = await Deno.readFile(path);
|
||||
} catch (error) {
|
||||
if (!(error instanceof Deno.errors.NotFound) && !(error instanceof Deno.errors.IsADirectory)) {
|
||||
throw error;
|
||||
}
|
||||
bytes = await Deno.readFile(join(buildRoot, "index.html"));
|
||||
extension = ".html";
|
||||
}
|
||||
// productionのmarkup/style/moduleを保ち、Setupだけbrowser API境界を代替する。
|
||||
// このbootstrapはnavigator.credentialsのみを置換し、formや表示を操作しない。
|
||||
if (extension === ".html" && mode.startsWith("setup-")) {
|
||||
bytes = new TextEncoder().encode(
|
||||
new TextDecoder().decode(bytes).replace(
|
||||
"<head>",
|
||||
'<head><script src="/__ui_fixture/webauthn.js"></script>',
|
||||
),
|
||||
);
|
||||
}
|
||||
const headers = new Headers({
|
||||
"content-type": mime[extension] ?? "application/octet-stream",
|
||||
"cache-control": "no-store",
|
||||
});
|
||||
if (selected) headers.set("set-cookie", `ui-fixture=${selected}; Path=/; SameSite=Strict`);
|
||||
return new Response(bytes as Uint8Array<ArrayBuffer>, { headers });
|
||||
});
|
||||
@@ -0,0 +1,59 @@
|
||||
import { resolve } from "@std/path";
|
||||
import type { Scenario } from "../../../../tools/web-ux/src/types.ts";
|
||||
const root = resolve(import.meta.dirname!, "../../../..");
|
||||
for (const theme of ["light", "dark"]) {
|
||||
const scenario: Scenario = JSON.parse(
|
||||
await Deno.readTextFile(`${root}/tools/web-ux/scenarios/hosting-settings-${theme}.json`),
|
||||
);
|
||||
scenario.id = `hosting-baseline-${theme}`;
|
||||
scenario.title = "Before: tracked HEAD Account and global navigation; no hosting route existed";
|
||||
scenario.routes = [
|
||||
{
|
||||
id: "account-on",
|
||||
label: "Anonymous Account before",
|
||||
path: "/account?fixture=account-on",
|
||||
goal: "Compare existing Account registration/login before policy gating.",
|
||||
dataState: "Synthetic anonymous session; unchanged baseline production shell.",
|
||||
ready: { kind: "selector", selector: 'button:has-text("Register new account"):enabled' },
|
||||
capturePoints: [{ id: "initial", label: "Before anonymous Account" }],
|
||||
},
|
||||
{
|
||||
id: "admin-account",
|
||||
label: "Signed-in Account before",
|
||||
path: "/account?fixture=admin-account",
|
||||
goal: "Compare Account and global navigation before admin settings link.",
|
||||
dataState: "Synthetic signed-in session; no hosting route/link before.",
|
||||
ready: { kind: "selector", selector: 'button:has-text("Add passkey to alice")' },
|
||||
capturePoints: [{ id: "initial", label: "Before signed-in Account" }],
|
||||
},
|
||||
];
|
||||
scenario.processes![0].cwd = root;
|
||||
scenario.processes![0].args![scenario.processes![0].args!.length - 1] =
|
||||
"target/web-ux/baseline-source/web/workspace/build";
|
||||
for (const route of scenario.routes) route.capturePoints[0].fullPage = true;
|
||||
await Deno.writeTextFile(
|
||||
`${root}/target/web-ux/baseline-${theme}.json`,
|
||||
JSON.stringify(scenario, null, 2),
|
||||
);
|
||||
for (const version of ["before", "after"]) {
|
||||
const nav = structuredClone(scenario);
|
||||
nav.id = `hosting-navigation-${version}-${theme}`;
|
||||
nav.title = `${version} mobile global navigation on production shell`;
|
||||
nav.viewports = nav.viewports.filter((item) => item.width === 390);
|
||||
for (const route of nav.routes) {
|
||||
route.capturePoints = [{
|
||||
id: "navigation",
|
||||
label: "Mobile global links",
|
||||
interaction: [{ action: "click", selector: 'button[aria-label="Show sidebar"]' }],
|
||||
ready: { kind: "selector", selector: 'nav[aria-label="Global pages"]' },
|
||||
}];
|
||||
}
|
||||
if (version === "after") {
|
||||
nav.processes![0].args![nav.processes![0].args!.length - 1] = "web/workspace/build";
|
||||
}
|
||||
await Deno.writeTextFile(
|
||||
`${root}/target/web-ux/navigation-${version}-${theme}.json`,
|
||||
JSON.stringify(nav, null, 2),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
import { resolve } from "@std/path";
|
||||
import type { RouteScenario, Scenario } from "../../../../tools/web-ux/src/types.ts";
|
||||
const root = resolve(import.meta.dirname!, "../../../..");
|
||||
const selector = (selector: string) => ({ kind: "selector" as const, selector });
|
||||
const click = (selector: string) => ({ action: "click" as const, selector });
|
||||
const press = (selector: string, key: string) => ({ action: "press" as const, selector, key });
|
||||
const route = (
|
||||
id: string,
|
||||
path: string,
|
||||
ready: string,
|
||||
dataState: string,
|
||||
points = [{ id: "initial", label: "Initial state" }],
|
||||
): RouteScenario => ({
|
||||
id,
|
||||
label: id,
|
||||
path: `${path}?fixture=${id}`,
|
||||
goal: "Verify production shell UI projection, not server authorization.",
|
||||
dataState,
|
||||
ready: selector(ready),
|
||||
capturePoints: points,
|
||||
});
|
||||
const routes: RouteScenario[] = [
|
||||
...["on", "off"].map((policy) =>
|
||||
route(
|
||||
`admin-${policy}`,
|
||||
"/hosting/settings",
|
||||
'button:has-text("Edit registration policy")',
|
||||
`Synthetic admin; policy ${policy}.`,
|
||||
[
|
||||
{ id: "initial", label: "Loaded hosting-wide policy" },
|
||||
{
|
||||
id: "edit",
|
||||
label: "Keyboard edit focus",
|
||||
interaction: [press('button:has-text("Edit registration policy")', "Enter")],
|
||||
ready: selector('input[type="checkbox"]:focus'),
|
||||
},
|
||||
{
|
||||
id: "saved",
|
||||
label: "Confirmed save",
|
||||
interaction: [
|
||||
press('input[type="checkbox"]', "Space"),
|
||||
click('button:has-text("Save settings")'),
|
||||
],
|
||||
ready: selector('[role="status"]:has-text("Hosting settings saved.")'),
|
||||
},
|
||||
] as RouteScenario["capturePoints"],
|
||||
)
|
||||
),
|
||||
route(
|
||||
"ordinary",
|
||||
"/hosting/settings",
|
||||
'p:has-text("Hosting administrator access is required.")',
|
||||
"Synthetic authenticated ordinary user, no admin controls.",
|
||||
),
|
||||
route(
|
||||
"anonymous",
|
||||
"/hosting/settings",
|
||||
'a:has-text("Open Account")',
|
||||
"Synthetic anonymous visitor; sign-in guidance, no controls.",
|
||||
),
|
||||
route(
|
||||
"admin-loading",
|
||||
"/hosting/settings",
|
||||
'[role="status"]:has-text("Loading hosting settings")',
|
||||
"Settings GET intentionally held pending; shell remains usable.",
|
||||
),
|
||||
route(
|
||||
"admin-load-error",
|
||||
"/hosting/settings",
|
||||
'button:has-text("Retry loading settings")',
|
||||
"Intentional HTTP503; expected console resource error and visible alert.",
|
||||
),
|
||||
...["saving", "save-error"].map((state) =>
|
||||
route(
|
||||
`admin-${state}`,
|
||||
"/hosting/settings",
|
||||
'button:has-text("Edit registration policy")',
|
||||
state === "saving"
|
||||
? "PUT held pending; last loaded policy retained, edit controls disabled."
|
||||
: "Intentional PUT HTTP503; outcome unknown, retry blocked until reload.",
|
||||
[
|
||||
{
|
||||
id: state,
|
||||
label: state,
|
||||
interaction: [
|
||||
click('button:has-text("Edit registration policy")'),
|
||||
press('input[type="checkbox"]', "Space"),
|
||||
click('button:has-text("Save settings")'),
|
||||
],
|
||||
ready: selector(state === "saving" ? 'button:has-text("Saving…")' : '[role="alert"]'),
|
||||
},
|
||||
] as RouteScenario["capturePoints"],
|
||||
)
|
||||
),
|
||||
route(
|
||||
"account-on",
|
||||
"/account",
|
||||
'button:has-text("Register new account"):enabled',
|
||||
"Anonymous policy on; registration and login available.",
|
||||
),
|
||||
route(
|
||||
"account-off",
|
||||
"/account",
|
||||
'p:has-text("New account registration is disabled.")',
|
||||
"Anonymous policy off; no registration action, login available.",
|
||||
),
|
||||
route(
|
||||
"admin-account",
|
||||
"/account",
|
||||
'button:has-text("Add passkey to alice")',
|
||||
"Authenticated admin; own passkey addition and global settings link.",
|
||||
),
|
||||
route(
|
||||
"ordinary-account-off",
|
||||
"/account",
|
||||
'button:has-text("Add passkey to alice")',
|
||||
"Authenticated ordinary user with policy off; own passkey addition available without admin nav.",
|
||||
),
|
||||
route(
|
||||
"account-error",
|
||||
"/account",
|
||||
'button:has-text("Retry registration availability")',
|
||||
"Intentional policy HTTP503; expected console resource error and visible alert; login retained.",
|
||||
),
|
||||
route(
|
||||
"account-loading",
|
||||
"/account",
|
||||
'[role="status"]:has-text("Checking registration availability")',
|
||||
"Policy request held pending; login remains available after session loads.",
|
||||
),
|
||||
];
|
||||
for (const account of routes.filter((item) => item.path.startsWith("/account"))) {
|
||||
account.capturePoints[0].fullPage = true;
|
||||
}
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
const scenario: Scenario = {
|
||||
schemaVersion: 1,
|
||||
id: `hosting-settings-${theme}`,
|
||||
title: "Hosting settings: synthetic UI conditions on production shell",
|
||||
baseUrl: "http://127.0.0.1:15378",
|
||||
colorScheme: theme,
|
||||
reducedMotion: "reduce",
|
||||
personas: [{
|
||||
id: "fixture",
|
||||
label: "Synthetic API projection; no real authentication",
|
||||
auth: { kind: "anonymous" },
|
||||
}],
|
||||
viewports: [{ label: "desktop", width: 1440, height: 900 }, {
|
||||
label: "narrow",
|
||||
width: 768,
|
||||
height: 900,
|
||||
}, { label: "mobile", width: 390, height: 844 }],
|
||||
routes,
|
||||
processes: [{
|
||||
id: "hosting-ui-fixture",
|
||||
command: "deno",
|
||||
cwd: "../../..",
|
||||
args: [
|
||||
"run",
|
||||
"--config",
|
||||
"tools/web-ux/deno.json",
|
||||
"--allow-read",
|
||||
"--allow-net",
|
||||
"web/workspace/test/hosting-browser/fixture-server.ts",
|
||||
"15378",
|
||||
"web/workspace/build",
|
||||
],
|
||||
readyUrl: "http://127.0.0.1:15378/health",
|
||||
}],
|
||||
};
|
||||
await Deno.writeTextFile(
|
||||
`${root}/tools/web-ux/scenarios/hosting-settings-${theme}.json`,
|
||||
JSON.stringify(scenario, null, 2) + "\n",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { resolve } from "@std/path";
|
||||
import type { Interaction, Scenario } from "../../../../tools/web-ux/src/types.ts";
|
||||
const root = resolve(import.meta.dirname!, "../../../..");
|
||||
const submit: Interaction[] = [{
|
||||
action: "fill",
|
||||
selector: "#host-setup-token",
|
||||
value: "${WEB_UX_SYNTHETIC_SETUP_INPUT}",
|
||||
}, { action: "click", selector: 'button:has-text("Add administrator passkey")' }];
|
||||
for (const theme of ["light", "dark"]) {
|
||||
const scenario: Scenario = JSON.parse(
|
||||
await Deno.readTextFile(`${root}/tools/web-ux/scenarios/hosting-settings-${theme}.json`),
|
||||
);
|
||||
scenario.id = `hosting-setup-${theme}`;
|
||||
scenario.title =
|
||||
"Hosting setup production UI: synthetic HTTP and navigator.credentials boundary only";
|
||||
scenario.redact = { text: ["${WEB_UX_SYNTHETIC_SETUP_INPUT}"] };
|
||||
scenario.routes = [{
|
||||
id: "setup-flow",
|
||||
label: "Setup enrollment and login UI",
|
||||
path: "/hosting/setup?fixture=setup-flow",
|
||||
goal:
|
||||
"Separate host-token enrollment from explicit passkey login and verified settings access.",
|
||||
dataState:
|
||||
"Synthetic actor, API and non-cryptographic navigator.credentials; no real token/authentication. Test-only bootstrap changes browser API boundary, not production UI modules/styles.",
|
||||
ready: { kind: "selector", selector: "#host-setup-token:enabled" },
|
||||
capturePoints: [
|
||||
{ id: "initial", label: "Empty password field; enrollment disabled", fullPage: true },
|
||||
{
|
||||
id: "host-help",
|
||||
label: "Trusted-host instructions expanded",
|
||||
interaction: [{ action: "press", selector: "summary", key: "Enter" }],
|
||||
ready: { kind: "selector", selector: "details[open]" },
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
id: "masked-entry",
|
||||
label: "Synthetic input password-masked; action enabled",
|
||||
interaction: [{ action: "press", selector: "summary", key: "Enter" }, submit[0]],
|
||||
ready: {
|
||||
kind: "selector",
|
||||
selector: 'button:has-text("Add administrator passkey"):enabled',
|
||||
},
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
id: "enrolled",
|
||||
label: "Enrollment is not login; explicit login handoff",
|
||||
interaction: [submit[1]],
|
||||
ready: {
|
||||
kind: "selector",
|
||||
selector: 'button:has-text("Sign in with new passkey"):enabled',
|
||||
},
|
||||
fullPage: true,
|
||||
},
|
||||
{
|
||||
id: "verified",
|
||||
label: "Matching actor/session/access projected verified; settings link",
|
||||
interaction: [{
|
||||
action: "press",
|
||||
selector: 'button:has-text("Sign in with new passkey")',
|
||||
key: "Enter",
|
||||
}],
|
||||
ready: { kind: "selector", selector: 'a:has-text("Open hosting settings")' },
|
||||
fullPage: true,
|
||||
},
|
||||
],
|
||||
}, {
|
||||
id: "setup-reject",
|
||||
label: "Rejected setup enrollment",
|
||||
path: "/hosting/setup?fixture=setup-reject",
|
||||
goal:
|
||||
"Show safe unconfirmed-enrollment guidance with empty cleared token and no settings action.",
|
||||
dataState:
|
||||
"Intentional addition/options HTTP403. Expected console/resource diagnostics; no real authorization exercised.",
|
||||
ready: { kind: "selector", selector: "#host-setup-token:enabled" },
|
||||
capturePoints: [{
|
||||
id: "rejected",
|
||||
label: "Rejected attempt; token cleared",
|
||||
interaction: submit,
|
||||
ready: { kind: "selector", selector: '[role="alert"]' },
|
||||
fullPage: true,
|
||||
}],
|
||||
}];
|
||||
await Deno.writeTextFile(
|
||||
`${root}/tools/web-ux/scenarios/hosting-setup-${theme}.json`,
|
||||
JSON.stringify(scenario, null, 2) + "\n",
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
/// <reference lib="dom" />
|
||||
// Production-shell browser behavior with synthetic HTTP projections only.
|
||||
// Run against prebuilt assets; never builds or uses a real session/backend.
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { resolve } from "@std/path";
|
||||
import { chromium, type Page } from "playwright";
|
||||
import {
|
||||
startOwnedProcesses,
|
||||
stopOwnedProcesses,
|
||||
} from "../../../../tools/web-ux/src/processes.ts";
|
||||
const root = resolve(import.meta.dirname!, "../../../..");
|
||||
const evidence = `${root}/target/web-ux/hosting/interactions`;
|
||||
async function noOverflow(page: Page) {
|
||||
assertEquals(
|
||||
await page.evaluate(() => ({
|
||||
page: document.documentElement.scrollWidth > innerWidth,
|
||||
main: [...document.querySelectorAll("main")].some((main) =>
|
||||
main.scrollWidth > main.clientWidth
|
||||
),
|
||||
})),
|
||||
{ page: false, main: false },
|
||||
);
|
||||
}
|
||||
Deno.test("hosting UI keyboard navigation, policy editing, denial and unknown save outcome on production shell", async () => {
|
||||
const processes = await startOwnedProcesses(
|
||||
[{
|
||||
id: "hosting-interactions",
|
||||
command: "deno",
|
||||
cwd: root,
|
||||
args: [
|
||||
"run",
|
||||
"--config",
|
||||
"tools/web-ux/deno.json",
|
||||
"--allow-net",
|
||||
"--allow-read",
|
||||
"web/workspace/test/hosting-browser/fixture-server.ts",
|
||||
"15379",
|
||||
"web/workspace/build",
|
||||
],
|
||||
readyUrl: "http://127.0.0.1:15379/health",
|
||||
readyTimeoutMs: 10000,
|
||||
}],
|
||||
`${root}/tools/web-ux/scenarios/hosting-settings-light.json`,
|
||||
evidence,
|
||||
[],
|
||||
);
|
||||
const browser = await chromium.launch();
|
||||
const results: unknown[] = [];
|
||||
try {
|
||||
for (const theme of ["light", "dark"] as const) {
|
||||
for (const width of [1440, 768, 390]) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height: 900 },
|
||||
colorScheme: theme,
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
const errors: string[] = [];
|
||||
const consoleErrors: string[] = [];
|
||||
const httpErrors: string[] = [];
|
||||
const failedRequests: { url: string; error: string }[] = [];
|
||||
page.on("pageerror", (error) => errors.push(String(error)));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") consoleErrors.push(message.text());
|
||||
});
|
||||
page.on("response", (response) => {
|
||||
if (response.status() >= 400) {
|
||||
httpErrors.push(
|
||||
`${response.status()} ${new URL(response.url()).pathname}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
page.on(
|
||||
"requestfailed",
|
||||
(request) =>
|
||||
failedRequests.push({
|
||||
url: request.url(),
|
||||
error: request.failure()?.errorText ?? "unknown",
|
||||
}),
|
||||
);
|
||||
const settingsRequests: string[] = [];
|
||||
page.on("request", (request) => {
|
||||
if (new URL(request.url()).pathname === "/api/hosting/settings") {
|
||||
settingsRequests.push(request.method());
|
||||
}
|
||||
});
|
||||
await page.goto("http://127.0.0.1:15379/?fixture=admin-on");
|
||||
await page.getByRole("heading", { name: "Yoi", exact: true })
|
||||
.waitFor();
|
||||
if (
|
||||
await page.getByRole("button", {
|
||||
name: "Show sidebar",
|
||||
exact: true,
|
||||
}).isVisible()
|
||||
) {
|
||||
await page.getByRole("button", {
|
||||
name: "Show sidebar",
|
||||
exact: true,
|
||||
}).click();
|
||||
}
|
||||
const nav = page.getByRole("navigation", { name: "Your account" });
|
||||
await nav.getByRole("link", { name: "Hosting settings", exact: true })
|
||||
.waitFor();
|
||||
await nav.getByRole("link", { name: "Account", exact: true }).focus();
|
||||
await page.keyboard.press("Tab");
|
||||
assertEquals(
|
||||
await page.locator(":focus").innerText(),
|
||||
"Hosting settings",
|
||||
);
|
||||
assertEquals(
|
||||
await page.locator(":focus").evaluate((element) =>
|
||||
getComputedStyle(element).outlineStyle !== "none"
|
||||
),
|
||||
true,
|
||||
);
|
||||
await page.keyboard.press("Enter");
|
||||
const edit = page.getByRole("button", {
|
||||
name: "Edit registration policy",
|
||||
});
|
||||
await edit.waitFor();
|
||||
// Globalの入口からhostingへ進んだ後はWorkspace shellを離れる。
|
||||
// Workspace未作成でも管理できることと整合させる。
|
||||
assertEquals(await nav.count(), 0);
|
||||
await page.getByRole("heading", {
|
||||
name: "Hosting settings",
|
||||
exact: true,
|
||||
}).waitFor();
|
||||
if (
|
||||
await page.getByRole("button", {
|
||||
name: "Hide sidebar",
|
||||
exact: true,
|
||||
}).isVisible() &&
|
||||
width < 768
|
||||
) {
|
||||
await page.getByRole("button", {
|
||||
name: "Hide sidebar",
|
||||
exact: true,
|
||||
}).click();
|
||||
}
|
||||
await noOverflow(page);
|
||||
await edit.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
const checkbox = page.getByRole("checkbox", {
|
||||
name: "Allow new account registration",
|
||||
});
|
||||
assertEquals(
|
||||
await checkbox.evaluate((element) =>
|
||||
element === document.activeElement
|
||||
),
|
||||
true,
|
||||
);
|
||||
assertEquals(await checkbox.isChecked(), true);
|
||||
await page.keyboard.press("Space");
|
||||
await page.getByRole("button", { name: "Cancel", exact: true })
|
||||
.click();
|
||||
assertEquals(
|
||||
await edit.evaluate((element) =>
|
||||
element === document.activeElement
|
||||
),
|
||||
true,
|
||||
);
|
||||
await page.keyboard.press("Enter");
|
||||
assertEquals(await checkbox.isChecked(), true);
|
||||
await page.keyboard.press("Space");
|
||||
await page.keyboard.press("Tab");
|
||||
assertEquals(
|
||||
await page.locator(":focus").innerText(),
|
||||
"Save settings",
|
||||
);
|
||||
await page.keyboard.press("Enter");
|
||||
await page.getByRole("status").filter({
|
||||
hasText: "Hosting settings saved.",
|
||||
}).waitFor();
|
||||
assertEquals(
|
||||
await edit.evaluate((element) =>
|
||||
element === document.activeElement
|
||||
),
|
||||
true,
|
||||
);
|
||||
await page.getByText("Disabled", { exact: true }).waitFor();
|
||||
assertEquals(
|
||||
settingsRequests.filter((method) => method === "PUT").length,
|
||||
1,
|
||||
);
|
||||
await noOverflow(page);
|
||||
for (const mode of ["ordinary", "anonymous"]) {
|
||||
settingsRequests.length = 0;
|
||||
await page.goto(
|
||||
`http://127.0.0.1:15379/hosting/settings?fixture=${mode}`,
|
||||
);
|
||||
await page.getByText(
|
||||
mode === "ordinary"
|
||||
? "Hosting administrator access is required."
|
||||
: "Sign in with a hosting administrator account to view these settings.",
|
||||
{ exact: true },
|
||||
).waitFor();
|
||||
assertEquals(await edit.count(), 0);
|
||||
assertEquals(await checkbox.count(), 0);
|
||||
assertEquals(
|
||||
await nav.getByRole("link", {
|
||||
name: "Hosting settings",
|
||||
exact: true,
|
||||
}).count(),
|
||||
0,
|
||||
);
|
||||
assertEquals(settingsRequests, []);
|
||||
await noOverflow(page);
|
||||
}
|
||||
await page.goto(
|
||||
"http://127.0.0.1:15379/register?fixture=account-off",
|
||||
);
|
||||
await page.getByText(
|
||||
"New account registration is disabled. Existing users can still log in.",
|
||||
).waitFor();
|
||||
assertEquals(
|
||||
await page.getByRole("button", { name: "Register new account" })
|
||||
.count(),
|
||||
0,
|
||||
);
|
||||
await page.getByRole("link", {
|
||||
name: "Log in with an existing account",
|
||||
}).waitFor();
|
||||
await noOverflow(page);
|
||||
await page.goto(
|
||||
"http://127.0.0.1:15379/account?fixture=ordinary-account-off",
|
||||
);
|
||||
await page.getByRole("button", { name: "Add passkey to alice" })
|
||||
.waitFor();
|
||||
assertEquals(
|
||||
await page.getByRole("button", { name: "Add passkey to alice" })
|
||||
.isEnabled(),
|
||||
true,
|
||||
);
|
||||
await page.goto(
|
||||
"http://127.0.0.1:15379/hosting/settings?fixture=admin-save-error",
|
||||
);
|
||||
await edit.click();
|
||||
await checkbox.press("Space");
|
||||
await page.getByRole("button", { name: "Save settings", exact: true })
|
||||
.click();
|
||||
await page.getByRole("alert").waitFor();
|
||||
assertEquals(await checkbox.isChecked(), false);
|
||||
assertEquals(await checkbox.isDisabled(), true);
|
||||
assertEquals(
|
||||
await page.getByRole("button", {
|
||||
name: "Save settings",
|
||||
exact: true,
|
||||
}).isDisabled(),
|
||||
true,
|
||||
);
|
||||
await noOverflow(page);
|
||||
await page.getByRole("button", {
|
||||
name: "Reload settings",
|
||||
exact: true,
|
||||
}).click();
|
||||
await edit.waitFor();
|
||||
assertEquals(await page.getByRole("alert").count(), 0);
|
||||
assertEquals(await edit.isEnabled(), true);
|
||||
for (
|
||||
const [mode, path, ready] of [
|
||||
[
|
||||
"admin-load-error",
|
||||
"/hosting/settings",
|
||||
'button:has-text("Retry loading settings")',
|
||||
],
|
||||
[
|
||||
"account-error",
|
||||
"/register",
|
||||
'button:has-text("Retry registration availability")',
|
||||
],
|
||||
[
|
||||
"admin-loading",
|
||||
"/hosting/settings",
|
||||
'[role="status"]:has-text("Loading hosting settings")',
|
||||
],
|
||||
[
|
||||
"account-loading",
|
||||
"/register",
|
||||
'[role="status"]:has-text("Checking registration availability")',
|
||||
],
|
||||
]
|
||||
) {
|
||||
await page.goto(`http://127.0.0.1:15379${path}?fixture=${mode}`);
|
||||
await page.locator(ready).waitFor();
|
||||
await noOverflow(page);
|
||||
}
|
||||
// Navigation may also cancel the shell's concurrent identity/access
|
||||
// reads. Only an explicit browser cancellation of these reads is expected.
|
||||
assert(
|
||||
failedRequests.every(({ url, error }) =>
|
||||
error === "net::ERR_ABORTED" && [
|
||||
"/api/hosting/settings",
|
||||
"/api/auth/registration-policy",
|
||||
"/api/auth/whoami",
|
||||
"/api/hosting/access",
|
||||
].some((path) => url.endsWith(path))
|
||||
),
|
||||
JSON.stringify(failedRequests),
|
||||
);
|
||||
assertEquals(httpErrors, [
|
||||
"503 /api/hosting/settings",
|
||||
"503 /api/hosting/settings",
|
||||
"503 /api/auth/registration-policy",
|
||||
]);
|
||||
assertEquals(consoleErrors.length, 3);
|
||||
assert(consoleErrors.every((message) => message.includes("503")));
|
||||
assertEquals(errors, []);
|
||||
results.push({
|
||||
theme,
|
||||
width,
|
||||
keyboard: "pass",
|
||||
overflow: "none",
|
||||
deniedControls: "absent",
|
||||
save: "confirmed",
|
||||
unconfirmedSave: "blocked until reload",
|
||||
realAuthorizationTested: false,
|
||||
});
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
await Deno.writeTextFile(
|
||||
`${evidence}/results.json`,
|
||||
JSON.stringify(results, null, 2),
|
||||
);
|
||||
} finally {
|
||||
await browser.close();
|
||||
await stopOwnedProcesses(processes);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,64 @@
|
||||
// Setupの表示条件だけを代替する。Bearerやrequest bodyは読まず、記録もしない。
|
||||
// WebAuthnは固定の非暗号credentialを返すため、本物の認証・署名検証ではない。
|
||||
export const setupWebAuthnProjection = `
|
||||
(() => {
|
||||
const bytes = () => new Uint8Array([117, 105]).buffer;
|
||||
const core = () => ({ id: 'ui-projection-only', rawId: bytes(), type: 'public-key', getClientExtensionResults: () => ({}) });
|
||||
Object.defineProperty(navigator.credentials, 'create', { configurable: true, value: async () => ({ ...core(), response: { clientDataJSON: bytes(), attestationObject: bytes(), getTransports: () => ['internal'] } }) });
|
||||
Object.defineProperty(navigator.credentials, 'get', { configurable: true, value: async () => ({ ...core(), response: { clientDataJSON: bytes(), authenticatorData: bytes(), signature: bytes(), userHandle: null } }) });
|
||||
})();
|
||||
`;
|
||||
const user = {
|
||||
user_id: "fixture-user",
|
||||
account_id: "fixture-account",
|
||||
handle: "alice",
|
||||
display_name: "Alice",
|
||||
};
|
||||
const stages = new Map<string, "enrolled" | "login">();
|
||||
export function setupProjectionResponse(
|
||||
path: string,
|
||||
mode: string,
|
||||
reset: boolean,
|
||||
): Response | null {
|
||||
if (!mode.startsWith("setup-")) return null;
|
||||
if (reset) stages.delete(mode);
|
||||
if (path === "/__ui_fixture/webauthn.js") {
|
||||
return new Response(setupWebAuthnProjection, {
|
||||
headers: { "content-type": "text/javascript" },
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/whoami") {
|
||||
return Response.json({
|
||||
actor: stages.get(mode) === "login" ? { ...user, auth_method: "browser_session" } : null,
|
||||
});
|
||||
}
|
||||
if (path === "/api/hosting/access") {
|
||||
return Response.json({ is_admin: stages.get(mode) === "login" });
|
||||
}
|
||||
if (path === "/api/auth/passkeys/addition/options") {
|
||||
if (mode === "setup-reject") {
|
||||
return new Response("synthetic enrollment rejection", { status: 403 });
|
||||
}
|
||||
return Response.json({
|
||||
challenge_id: "fixture-add",
|
||||
public_key: {
|
||||
challenge: "dWk",
|
||||
rp: { name: "UI projection" },
|
||||
user: { id: "dWk", name: "alice", displayName: "Alice" },
|
||||
pubKeyCredParams: [{ type: "public-key", alg: -7 }],
|
||||
},
|
||||
});
|
||||
}
|
||||
if (path === "/api/auth/passkeys/addition/complete") {
|
||||
stages.set(mode, "enrolled");
|
||||
return Response.json({ user });
|
||||
}
|
||||
if (path === "/api/auth/passkeys/login/options") {
|
||||
return Response.json({ challenge_id: "fixture-login", public_key: { challenge: "dWk" } });
|
||||
}
|
||||
if (path === "/api/auth/passkeys/login/complete") {
|
||||
stages.set(mode, "login");
|
||||
return Response.json({ user });
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -0,0 +1,184 @@
|
||||
/// <reference lib="dom" />
|
||||
// UI projectionのみ。実APIのBearer認可・WebAuthn・cookie発行は検証しない。
|
||||
// requestを観測する場合もmethod/pathだけとし、headers/bodyは取得・保存しない。
|
||||
import { assert, assertEquals } from "@std/assert";
|
||||
import { resolve } from "@std/path";
|
||||
import { chromium, type Page } from "playwright";
|
||||
import { startOwnedProcesses, stopOwnedProcesses } from "../../../../tools/web-ux/src/processes.ts";
|
||||
const root = resolve(import.meta.dirname!, "../../../..");
|
||||
const evidence = `${root}/target/web-ux/hosting/setup-interactions`;
|
||||
async function noOverflow(page: Page) {
|
||||
assertEquals(
|
||||
await page.evaluate(() => ({
|
||||
page: document.documentElement.scrollWidth > innerWidth,
|
||||
main: [...document.querySelectorAll("main")].some((main) =>
|
||||
main.scrollWidth > main.clientWidth
|
||||
),
|
||||
})),
|
||||
{ page: false, main: false },
|
||||
);
|
||||
}
|
||||
Deno.test("setup UI separates masked host-token enrollment, rejected attempts, explicit login and verified settings navigation", async () => {
|
||||
const processes = await startOwnedProcesses(
|
||||
[{
|
||||
id: "setup-interactions",
|
||||
command: "deno",
|
||||
cwd: root,
|
||||
args: [
|
||||
"run",
|
||||
"--config",
|
||||
"tools/web-ux/deno.json",
|
||||
"--allow-net",
|
||||
"--allow-read",
|
||||
"web/workspace/test/hosting-browser/fixture-server.ts",
|
||||
"15380",
|
||||
"web/workspace/build",
|
||||
],
|
||||
readyUrl: "http://127.0.0.1:15380/health",
|
||||
readyTimeoutMs: 10000,
|
||||
}],
|
||||
`${root}/tools/web-ux/scenarios/hosting-setup-light.json`,
|
||||
evidence,
|
||||
[],
|
||||
);
|
||||
const browser = await chromium.launch();
|
||||
const results = [];
|
||||
try {
|
||||
for (const colorScheme of ["light", "dark"] as const) {
|
||||
for (const width of [1440, 768, 390]) {
|
||||
const context = await browser.newContext({
|
||||
viewport: { width, height: 900 },
|
||||
colorScheme,
|
||||
reducedMotion: "reduce",
|
||||
});
|
||||
try {
|
||||
const page = await context.newPage();
|
||||
const pageErrors: string[] = [],
|
||||
consoleErrors: string[] = [],
|
||||
httpErrors: string[] = [],
|
||||
requestFailures: string[] = [];
|
||||
const methodsAndPaths: string[] = [];
|
||||
page.on("pageerror", (error) => pageErrors.push(String(error)));
|
||||
page.on("console", (message) => {
|
||||
if (message.type() === "error") consoleErrors.push(message.text());
|
||||
});
|
||||
page.on("response", (response) => {
|
||||
if (response.status() >= 400) {
|
||||
httpErrors.push(`${response.status()} ${new URL(response.url()).pathname}`);
|
||||
}
|
||||
});
|
||||
page.on(
|
||||
"requestfailed",
|
||||
(request) => requestFailures.push(new URL(request.url()).pathname),
|
||||
);
|
||||
page.on(
|
||||
"request",
|
||||
(request) =>
|
||||
methodsAndPaths.push(`${request.method()} ${new URL(request.url()).pathname}`),
|
||||
);
|
||||
await page.goto("http://127.0.0.1:15380/hosting/setup?fixture=setup-flow");
|
||||
const input = page.locator("#host-setup-token");
|
||||
await input.waitFor();
|
||||
const enroll = page.getByRole("button", {
|
||||
name: "Add administrator passkey",
|
||||
exact: true,
|
||||
});
|
||||
assertEquals(await input.getAttribute("type"), "password");
|
||||
assertEquals(await enroll.isDisabled(), true);
|
||||
assert(
|
||||
!methodsAndPaths.some((entry) => entry.startsWith("POST ")),
|
||||
);
|
||||
assertEquals(
|
||||
await page.getByRole("link", { name: "Open hosting settings", exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
await noOverflow(page);
|
||||
await page.locator("summary").focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await page.locator("details[open]").waitFor();
|
||||
await noOverflow(page);
|
||||
await page.keyboard.press("Enter");
|
||||
const synthetic = `ui-projection-${crypto.randomUUID()}`;
|
||||
await input.fill(synthetic);
|
||||
assertEquals(await enroll.isEnabled(), true);
|
||||
await input.focus();
|
||||
await page.keyboard.press("Tab");
|
||||
assertEquals(
|
||||
await enroll.evaluate((element) => element === document.activeElement),
|
||||
true,
|
||||
);
|
||||
assertEquals(
|
||||
await enroll.evaluate((element) => getComputedStyle(element).outlineStyle !== "none"),
|
||||
true,
|
||||
);
|
||||
await page.keyboard.press("Enter");
|
||||
const login = page.getByRole("button", { name: "Sign in with new passkey", exact: true });
|
||||
await login.waitFor();
|
||||
await page.getByRole("status").filter({ hasText: "Enrollment is not a passkey login." })
|
||||
.waitFor();
|
||||
assertEquals(await input.count(), 0);
|
||||
assertEquals(
|
||||
await page.getByRole("link", { name: "Open hosting settings", exact: true }).count(),
|
||||
0,
|
||||
);
|
||||
assertEquals(
|
||||
methodsAndPaths.filter((entry) => entry.startsWith("POST ")),
|
||||
[
|
||||
"POST /api/auth/passkeys/addition/options",
|
||||
"POST /api/auth/passkeys/addition/complete",
|
||||
],
|
||||
);
|
||||
await noOverflow(page);
|
||||
await login.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
const settings = page.getByRole("link", { name: "Open hosting settings", exact: true });
|
||||
await settings.waitFor();
|
||||
await page.getByRole("status").filter({
|
||||
hasText: "Passkey login and hosting administrator access verified.",
|
||||
}).waitFor();
|
||||
await noOverflow(page);
|
||||
await settings.focus();
|
||||
await page.keyboard.press("Enter");
|
||||
await page.getByRole("button", { name: "Edit registration policy", exact: true })
|
||||
.waitFor();
|
||||
await noOverflow(page);
|
||||
await page.goto("http://127.0.0.1:15380/hosting/setup?fixture=setup-reject");
|
||||
await input.fill(synthetic);
|
||||
await enroll.click();
|
||||
const error = page.getByRole("alert");
|
||||
await error.waitFor();
|
||||
assert(!(await input.inputValue()));
|
||||
assertEquals(await enroll.isDisabled(), true);
|
||||
assert(!(await error.innerText()).includes(synthetic));
|
||||
assertEquals(await settings.count(), 0);
|
||||
await noOverflow(page);
|
||||
assertEquals(pageErrors, []);
|
||||
assertEquals(httpErrors, ["403 /api/auth/passkeys/addition/options"]);
|
||||
assertEquals(consoleErrors.length, 1);
|
||||
assert(consoleErrors[0].includes("403"));
|
||||
assert(requestFailures.every((path) => path === "/api/auth/passkeys/addition/options"));
|
||||
results.push({
|
||||
width,
|
||||
theme: colorScheme,
|
||||
emptyDisabled: true,
|
||||
passwordMasked: true,
|
||||
keyboard: "pass",
|
||||
tokenClearedAfterRejectedAttempt: true,
|
||||
enrollmentNotLogin: true,
|
||||
explicitLoginBeforeSettings: true,
|
||||
overflow: "none",
|
||||
unexpectedErrors: 0,
|
||||
requestHeadersOrBodiesRecorded: false,
|
||||
realAuthenticationTested: false,
|
||||
});
|
||||
} finally {
|
||||
await context.close();
|
||||
}
|
||||
}
|
||||
}
|
||||
await Deno.writeTextFile(`${evidence}/results.json`, JSON.stringify(results, null, 2));
|
||||
} finally {
|
||||
await browser.close();
|
||||
await stopOwnedProcesses(processes);
|
||||
}
|
||||
});
|
||||
@@ -1,5 +1,36 @@
|
||||
import { assertEquals } from "jsr:@std/assert";
|
||||
import { ssr } from "../src/routes/+layout.ts";
|
||||
import { load, ssr } from "../src/routes/+layout.ts";
|
||||
|
||||
Deno.test("authentication and hosting shells never require Workspace catalog authority", async () => {
|
||||
for (
|
||||
const path of [
|
||||
"/",
|
||||
"/dashboard",
|
||||
"/activate",
|
||||
"/login",
|
||||
"/register",
|
||||
"/account",
|
||||
"/login/device",
|
||||
"/hosting/accounts",
|
||||
"/hosting/settings",
|
||||
"/hosting/setup",
|
||||
]
|
||||
) {
|
||||
let calls = 0;
|
||||
const event = {
|
||||
url: new URL(path, "https://workspace.test"),
|
||||
fetch: () => {
|
||||
calls++;
|
||||
throw new Error("Workspace API must not be called");
|
||||
},
|
||||
} as unknown as Parameters<typeof load>[0];
|
||||
assertEquals(await load(event).deferredCatalog, {
|
||||
ok: true,
|
||||
data: { accessibleWorkspaces: [], workspaceCatalogError: null },
|
||||
});
|
||||
assertEquals(calls, 0);
|
||||
}
|
||||
});
|
||||
|
||||
Deno.test("workspace frontend serves deep links through the client SPA", () => {
|
||||
assertEquals(ssr, false);
|
||||
|
||||
@@ -11,6 +11,8 @@ import { dashboardFixture } from "../../src/lib/workspace/home/dashboard.test-fi
|
||||
|
||||
Deno.serve({ hostname: "127.0.0.1", port }, async (request) => {
|
||||
const url = new URL(request.url);
|
||||
if (url.pathname === "/api/auth/whoami") return Response.json({ actor: { user_id: "fixture-user", account_id: "fixture-owner", handle: "alice", display_name: "Alice", auth_method: "browser_session" } });
|
||||
if (url.pathname === "/api/hosting/access") return Response.json({ is_admin: false });
|
||||
if (url.pathname === "/api/workspaces" && request.method === "POST") {
|
||||
return Response.json({
|
||||
message:
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user