Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions crates/hypercolor-daemon/src/api/cloud.rs
Original file line number Diff line number Diff line change
Expand Up @@ -549,9 +549,20 @@ pub async fn logout(State(state): State<Arc<AppState>>) -> Response {
}
}

const MAX_PENDING_CLOUD_LOGIN_SESSIONS: usize = 128;

pub async fn start_login(State(state): State<Arc<AppState>>) -> Response {
let cloud_config = cloud_config(&state);
if !cloud_config.enabled {
return ApiError::conflict("cloud login is disabled");
}

prune_expired_login_sessions(&state).await;

if state.cloud_login_sessions.lock().await.len() >= MAX_PENDING_CLOUD_LOGIN_SESSIONS {
return ApiError::rate_limited("too many pending cloud login sessions");
}

let client = match cloud_client(&state) {
Ok(client) => client,
Err(error) => return ApiError::internal(format!("invalid cloud configuration: {error}")),
Expand Down
62 changes: 62 additions & 0 deletions crates/hypercolor-daemon/tests/cloud_api_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,50 @@ async fn cloud_login_start_stores_session_without_returning_device_code() {
shutdown_auth_server(shutdown_tx, task).await;
}

#[tokio::test]
async fn cloud_login_start_rejects_when_cloud_disabled() {
let (state, _data_dir) = login_test_state(false);
let app = api::build_router(state, None);

let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/cloud/login/start")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");

assert_eq!(response.status(), StatusCode::CONFLICT);
}

#[tokio::test]
async fn cloud_login_start_rejects_when_pending_session_limit_reached() {
let (state, _data_dir) = login_test_state(true);
for _ in 0..128 {
state.cloud_login_sessions.lock().await.insert(
uuid::Uuid::new_v4(),
DeviceAuthorizationSession::new(device_code_fixture(900)),
);
}
let app = api::build_router(Arc::clone(&state), None);

let response = app
.oneshot(
Request::builder()
.method("POST")
.uri("/api/v1/cloud/login/start")
.body(Body::empty())
.expect("request should build"),
)
.await
.expect("request should succeed");

assert_eq!(response.status(), StatusCode::TOO_MANY_REQUESTS);
}

#[tokio::test]
async fn cloud_login_poll_keeps_pending_session_retryable() {
let (auth_base_url, shutdown_tx, task) = spawn_auth_server().await;
Expand Down Expand Up @@ -921,6 +965,24 @@ fn cloud_test_state_with_cloud(auth_base_url: &str, enabled: bool) -> Arc<AppSta
Arc::new(state)
}

/// Build a fully isolated daemon state for cloud-login tests.
///
/// The asset library data dir is an explicit per-test temp dir rather than
/// the process-global override, so parallel tests never race opening the
/// shared asset index.
fn login_test_state(cloud_enabled: bool) -> (Arc<AppState>, TempDir) {
let tempdir = TempDir::new().expect("temp dir should be created");
let manager = ConfigManager::new(tempdir.path().join("config.toml"))
.expect("config manager should initialize");
let mut config = HypercolorConfig::default();
config.cloud.enabled = cloud_enabled;
manager.update(config);

let mut state = AppState::new_with_data_dir(tempdir.path().join("data"));
state.config_manager = Some(Arc::new(manager));
(Arc::new(state), tempdir)
}

fn prepare_input(
identity_nonce: [u8; 32],
upgrade_nonce: [u8; 16],
Expand Down
Loading