//! Task structures to request work properties.
use core::mem;
use std::sync::PoisonError;
use const_format::formatcp;
use educe::Educe;
use libthreema_macros::{DebugVariantNames, Name, VariantNames};
use prost::Message as _;
use tracing::{debug, info, warn};
#[cfg(feature = "uniffi")]
use crate::bindings::uniffi::ConversionError;
use crate::{
common::{
ClientInfo, ThreemaId,
config::{WorkContext, WorkServerBaseUrl},
keys::ClientKey,
task::TaskLoop,
},
https::{HttpsRequest, HttpsResult, endpoint::HttpsEndpointError, work_directory},
protobuf,
utils::debug::Name as _,
};
/// An error occurred while updating the work properties.
///
/// Note: Errors can occur when using the API incorrectly or when the remote server behaves incorrectly. None
/// of these errors are considered recoverable.
///
/// When encountering an error:
///
/// 1. Let `error` be the provided [`WorkPropertiesUpdateError`].
/// 2. If `error` is [`WorkPropertiesUpdateError::InvalidCredentials`], notify the user that the Work
/// credentials are invalid and request new ones before making a new attempt.
/// 3. Notify the user according to `error` with the option to manually retry and abort the task.
#[derive(Clone, Debug, thiserror::Error)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Error), uniffi(flat_error))]
#[cfg_attr(
feature = "wasm",
derive(tsify::Tsify, serde::Serialize),
serde(
tag = "type",
content = "details",
rename_all = "kebab-case",
rename_all_fields = "camelCase"
),
tsify(into_wasm_abi)
)]
pub enum WorkPropertiesUpdateError {
/// Some provided parameter could not be converted into the required internal representation.
#[error("Conversion error: {0}")]
ConversionError(String),
/// A contract violation occurred.
#[error("Contract violation: {0}")]
ContractViolation(&'static str),
/// An unrecoverable network error occurred while communicating with a server.
#[error("Network error: {0}")]
NetworkError(String),
/// A server misbehaved in an operation considered infallible.
#[error("Server error: {0}")]
ServerError(String),
/// Invalid credentials (should only be relevant for Work and OnPrem) reported by a server caused an
/// operation to fail.
///
/// When processing this variant, abort the task and notify the user that the Work credentials are invalid
/// and request new ones before making a new attempt.
#[error("Invalid credentials")]
InvalidCredentials,
/// A rate limit of a server has been exceeded.
#[error("Rate limit exceeded")]
RateLimitExceeded,
/// Unable to obtain a lock.
#[cfg(feature = "uniffi")]
#[error("Obtaining a lock failed")]
LockPoisoned,
}
impl From<HttpsEndpointError> for WorkPropertiesUpdateError {
fn from(error: HttpsEndpointError) -> Self {
match error {
HttpsEndpointError::NetworkError(_) | HttpsEndpointError::ChallengeExpired => {
Self::NetworkError(error.to_string())
},
HttpsEndpointError::InvalidCredentials => Self::InvalidCredentials,
HttpsEndpointError::RateLimitExceeded => Self::RateLimitExceeded,
HttpsEndpointError::Forbidden
| HttpsEndpointError::NotFound
| HttpsEndpointError::InvalidChallenge
| HttpsEndpointError::InvalidChallengeResponse
| HttpsEndpointError::UnexpectedStatus(_)
| HttpsEndpointError::DecodingFailed(_)
| HttpsEndpointError::CustomPossiblyLocalizedError(_) => Self::ServerError(error.to_string()),
}
}
}
#[cfg(feature = "uniffi")]
impl From<ConversionError> for WorkPropertiesUpdateError {
fn from(error: ConversionError) -> Self {
Self::ConversionError(error.to_string())
}
}
#[cfg(feature = "uniffi")]
impl<T> From<PoisonError<T>> for WorkPropertiesUpdateError {
fn from(_: PoisonError<T>) -> Self {
Self::LockPoisoned
}
}
/// Context for updating the work properties.
pub struct WorkPropertiesUpdateContext {
/// Client info.
pub client_info: ClientInfo,
/// Work server URL from the configuration.
pub work_server_url: WorkServerBaseUrl,
/// Work (or OnPrem) application configuration.
pub work_context: WorkContext,
/// The user's identity.
pub user_identity: ThreemaId,
/// Client key.
pub client_key: ClientKey,
}
/// The work availability status.
///
/// The optional custom status description by the user should be ≤ 256 bytes and will be truncated (without
/// respecting grapheme cluster boundaries) during encoding otherwise. An empty string indicates that no
/// description was provided.
#[derive(Debug, Clone, PartialEq)]
#[cfg_attr(feature = "uniffi", derive(uniffi::Enum))]
#[cfg_attr(
feature = "wasm",
derive(tsify::Tsify, serde::Deserialize),
serde(rename_all = "camelCase")
)]
pub enum WorkAvailabilityStatus {
/// No status provided.
None,
/// _Unavailable_ status with an optional description.
Unavailable(String),
/// _Busy_ status with an optional description.
Busy(String),
}
impl WorkAvailabilityStatus {
/// Maximum byte length of the availability status description.
const DESCRIPTION_LENGTH_MAX: usize = 256;
}
impl From<WorkAvailabilityStatus> for protobuf::d2d_sync::WorkAvailabilityStatus {
fn from(availability_status: WorkAvailabilityStatus) -> Self {
let (category, mut description) = match availability_status {
WorkAvailabilityStatus::None => (
protobuf::d2d_sync::WorkAvailabilityStatusCategory::None,
String::new(),
),
WorkAvailabilityStatus::Unavailable(description) => (
protobuf::d2d_sync::WorkAvailabilityStatusCategory::Unavailable,
description,
),
WorkAvailabilityStatus::Busy(description) => (
protobuf::d2d_sync::WorkAvailabilityStatusCategory::Busy,
description,
),
};
// Truncate description to the maximum length.
if description.len() > WorkAvailabilityStatus::DESCRIPTION_LENGTH_MAX {
description
.truncate(description.floor_char_boundary(WorkAvailabilityStatus::DESCRIPTION_LENGTH_MAX));
warn!(new_description = ?description, "Truncated work availability status description");
}
// Map to protobuf.
Self {
category: category.into(),
description,
}
}
}
/// The user's work properties to update.
///
/// IMPORTANT: All fields are optional and presence indicates a change to a
/// property. Lack of presence means that the property remains unchanged.
#[cfg_attr(feature = "uniffi", derive(uniffi::Record))]
#[cfg_attr(
feature = "wasm",
derive(tsify::Tsify, serde::Deserialize),
serde(rename_all = "camelCase"),
tsify(from_wasm_abi)
)]
pub struct WorkProperties {
/// Availability status of the user.
pub availability_status: Option<WorkAvailabilityStatus>,
}
impl From<WorkProperties> for protobuf::directory::WorkProperties {
fn from(properties: WorkProperties) -> Self {
Self {
availability_status: properties.availability_status.map(WorkAvailabilityStatus::into),
}
}
}
/// 1. Run the HTTPS request as defined by [`HttpsRequest`] and let `response` be the result.
/// 2. Provide `response` to the associated task as a [`HttpsResult`] and poll again.
pub struct WorkPropertiesUpdateInstruction {
/// The HTTPs request to be made.
pub request: HttpsRequest,
}
/// Possible response to a [`WorkPropertiesUpdateInstruction`].
#[derive(Debug, Name)]
pub struct WorkPropertiesUpdateResponse {
/// The HTTPs request to be made.
pub result: HttpsResult,
}
/// Result of polling a [`WorkPropertiesUpdateTask`].
pub type WorkPropertiesUpdateLoop = TaskLoop<WorkPropertiesUpdateInstruction, ()>;
struct InitState {
work_properties: WorkProperties,
}
struct ChallengeState {
encoded_work_properties: Vec<u8>,
response: Option<WorkPropertiesUpdateResponse>,
}
struct UpdateState {
response: Option<WorkPropertiesUpdateResponse>,
}
#[derive(DebugVariantNames, VariantNames)]
enum State {
Error(WorkPropertiesUpdateError),
Init(InitState),
Challenge(ChallengeState),
Update(UpdateState),
Done,
}
impl State {
#[expect(clippy::unnecessary_wraps, reason = "Follow pattern")]
fn poll_init(
context: &WorkPropertiesUpdateContext,
state: InitState,
) -> Result<(Self, WorkPropertiesUpdateLoop), WorkPropertiesUpdateError> {
debug!("Requesting challenge to update the work properties");
let encoded_work_properties =
protobuf::directory::WorkProperties::from(state.work_properties).encode_to_vec();
let request = work_directory::update_work_properties_authentication_request(
&context.client_info,
&context.work_server_url,
&context.work_context,
context.user_identity,
encoded_work_properties.clone(),
);
Ok((
Self::Challenge(ChallengeState {
encoded_work_properties,
response: None,
}),
WorkPropertiesUpdateLoop::Instruction(WorkPropertiesUpdateInstruction { request }),
))
}
fn poll_challenge(
context: &WorkPropertiesUpdateContext,
state: ChallengeState,
) -> Result<(Self, WorkPropertiesUpdateLoop), WorkPropertiesUpdateError> {
// Ensure the caller provided the response.
let Some(response) = state.response else {
return Err(WorkPropertiesUpdateError::ContractViolation(formatcp!(
"{} result was not provided for '{}' state",
WorkPropertiesUpdateResponse::NAME,
State::CHALLENGE,
)));
};
// Handle the authentication challenge and provide the final request to update the work properties.
let authentication =
work_directory::handle_authentication_challenge(&context.client_key, response.result)?;
info!("Updating the work properties");
let request = work_directory::update_work_properties_request(
&context.client_info,
&context.work_server_url,
&context.work_context,
context.user_identity,
authentication,
state.encoded_work_properties,
);
Ok((
Self::Update(UpdateState { response: None }),
WorkPropertiesUpdateLoop::Instruction(WorkPropertiesUpdateInstruction { request }),
))
}
fn poll_update(
state: UpdateState,
) -> Result<(Self, WorkPropertiesUpdateLoop), WorkPropertiesUpdateError> {
// Ensure the caller provided the response.
let Some(response) = state.response else {
return Err(WorkPropertiesUpdateError::ContractViolation(formatcp!(
"{} result was not provided for '{}' state",
WorkPropertiesUpdateResponse::NAME,
State::UPDATE,
)));
};
// Handle the result.
work_directory::handle_update_work_properties_result(response.result)?;
info!("Updated work properties");
Ok((Self::Done, WorkPropertiesUpdateLoop::Done(())))
}
}
/// Task for updating the work properties.
#[derive(Name, Educe)]
#[educe(Debug)]
pub struct WorkPropertiesUpdateTask {
#[educe(Debug(ignore))]
context: WorkPropertiesUpdateContext,
state: State,
}
impl WorkPropertiesUpdateTask {
/// Create a new task for updating the work properties.
#[must_use]
pub fn new(context: WorkPropertiesUpdateContext, properties: WorkProperties) -> Self {
Self {
context,
state: State::Init(InitState {
work_properties: properties,
}),
}
}
/// Poll to advance the state.
///
/// # Errors
///
/// Returns [`WorkPropertiesUpdateError`] for all possible reasons.
#[tracing::instrument(skip_all, fields(?self))]
pub fn poll(&mut self) -> Result<WorkPropertiesUpdateLoop, WorkPropertiesUpdateError> {
let result = match mem::replace(
&mut self.state,
State::Error(WorkPropertiesUpdateError::ContractViolation(formatcp!(
"{} in a transitional state",
WorkPropertiesUpdateTask::NAME
))),
) {
State::Error(error) => Err(error),
State::Init(state) => State::poll_init(&self.context, state),
State::Challenge(state) => State::poll_challenge(&self.context, state),
State::Update(state) => State::poll_update(state),
State::Done => Err(WorkPropertiesUpdateError::ContractViolation(formatcp!(
"{} already done",
WorkPropertiesUpdateTask::NAME
))),
};
match result {
Ok((state, instruction)) => {
self.state = state;
debug!(state = ?self.state, "Changed state");
Ok(instruction)
},
Err(error) => {
self.state = State::Error(error.clone());
debug!(state = ?self.state, "Changed state to error");
Err(error)
},
}
}
/// Possible results after handling a [`WorkPropertiesUpdateInstruction`].
///
/// # Errors
///
/// Returns [`WorkPropertiesUpdateError`] for all possible reasons.
#[tracing::instrument(skip_all, fields(?self))]
pub fn response(
&mut self,
response: WorkPropertiesUpdateResponse,
) -> Result<(), WorkPropertiesUpdateError> {
match &mut self.state {
State::Challenge(state) => {
let _ = state.response.insert(response);
Ok(())
},
State::Update(state) => {
let _ = state.response.insert(response);
Ok(())
},
_ => Err(WorkPropertiesUpdateError::ContractViolation(formatcp!(
"Must be in '{}' or '{}' state",
State::CHALLENGE,
State::UPDATE,
))),
}
}
}
#[cfg(test)]
mod tests {
use assert_matches::assert_matches;
use derive_builder::Builder;
use rstest::rstest;
use serde_json::json;
use super::*;
use crate::{
common::{
ThreemaId,
config::{Config, WorkContext, WorkCredentials, WorkFlavor},
keys::ClientKey,
},
https::HttpsResponse,
};
fn context() -> WorkPropertiesUpdateContext {
let config = Config::testing();
WorkPropertiesUpdateContext {
client_info: ClientInfo::Libthreema,
work_server_url: config.work_server_url,
work_context: WorkContext {
credentials: WorkCredentials {
username: "nigel".to_owned(),
password: "nagel".to_owned(),
},
flavor: WorkFlavor::Work,
},
user_identity: ThreemaId::predefined(*b"TITATEST"),
client_key: ClientKey::from([0_u8; ClientKey::LENGTH]),
}
}
#[derive(Builder)]
#[builder(pattern = "owned")]
struct WorkPropertiesInit {
#[builder(default = None)]
availability_status: Option<WorkAvailabilityStatus>,
}
impl WorkPropertiesInitBuilder {
fn with_default_availability_status(self) -> Self {
self.availability_status(Some(WorkAvailabilityStatus::None))
}
}
impl From<WorkPropertiesInit> for WorkProperties {
fn from(value: WorkPropertiesInit) -> Self {
Self {
availability_status: value.availability_status,
}
}
}
#[test]
fn init() -> anyhow::Result<()> {
let context = context();
let work_properties = WorkPropertiesInitBuilder::default()
.with_default_availability_status()
.build()?;
let state = InitState {
work_properties: work_properties.into(),
};
let (state, instruction) = State::poll_init(&context, state)?;
let state = assert_matches!(state, State::Challenge(state) => state);
assert!(state.response.is_none());
assert_matches!(instruction, WorkPropertiesUpdateLoop::Instruction(_));
Ok(())
}
#[test]
fn challenge_without_response() {
let context = context();
let state = ChallengeState {
encoded_work_properties: vec![1_u8; 32],
response: None,
};
let result = State::poll_challenge(&context, state);
assert_matches!(result, Err(WorkPropertiesUpdateError::ContractViolation(_)));
}
#[test]
fn challenge_invalid() -> anyhow::Result<()> {
let context = context();
let state = ChallengeState {
encoded_work_properties: vec![1_u8; 32],
response: Some(WorkPropertiesUpdateResponse {
result: Ok(HttpsResponse {
status: 200,
body: serde_json::to_vec(&json!({
"challenge": "bWVvdw==",
}))?,
}),
}),
};
let result = State::poll_challenge(&context, state);
assert_matches!(result, Err(WorkPropertiesUpdateError::ServerError(_)));
Ok(())
}
#[test]
fn challenge_valid() -> anyhow::Result<()> {
let context = context();
let state = ChallengeState {
encoded_work_properties: vec![1_u8; 32],
response: Some(WorkPropertiesUpdateResponse {
result: Ok(HttpsResponse {
status: 200,
body: serde_json::to_vec(&json!({
"challengePublicKey": "BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"challenge": "bWVvdw==",
}))?,
}),
}),
};
let (state, instruction) = State::poll_challenge(&context, state)?;
let state = assert_matches!(state, State::Update(state) => state);
assert!(state.response.is_none());
assert_matches!(instruction, WorkPropertiesUpdateLoop::Instruction(_));
Ok(())
}
#[test]
fn update_without_response() {
let state = UpdateState { response: None };
let result = State::poll_update(state);
assert_matches!(result, Err(WorkPropertiesUpdateError::ContractViolation(_)));
}
#[rstest]
fn update_valid(#[values(200, 204)] status_code: u16) -> anyhow::Result<()> {
let state = UpdateState {
response: Some(WorkPropertiesUpdateResponse {
result: Ok(HttpsResponse {
status: status_code,
body: vec![],
}),
}),
};
let (state, instruction) = State::poll_update(state)?;
assert_matches!(state, State::Done);
assert_matches!(instruction, WorkPropertiesUpdateLoop::Done(()));
Ok(())
}
#[test]
fn unexpected_response() -> anyhow::Result<()> {
let work_properties = WorkPropertiesInitBuilder::default()
.with_default_availability_status()
.build()?;
let mut task = WorkPropertiesUpdateTask::new(context(), work_properties.into());
let result = task.response(WorkPropertiesUpdateResponse {
result: Err(crate::https::HttpsError::Timeout(
"row row row your boat".to_owned(),
)),
});
assert_matches!(result, Err(WorkPropertiesUpdateError::ContractViolation(_)));
Ok(())
}
#[test]
fn unexpected_poll_after_error() {
let mut task = WorkPropertiesUpdateTask {
context: context(),
state: State::Error(WorkPropertiesUpdateError::NetworkError("Heute nicht".to_owned())),
};
let result = task.poll();
assert_matches!(result, Err(WorkPropertiesUpdateError::NetworkError(_)));
}
#[test]
fn unexpected_poll_when_done() {
let mut task = WorkPropertiesUpdateTask {
context: context(),
state: State::Done,
};
let result = task.poll();
assert_matches!(result, Err(WorkPropertiesUpdateError::ContractViolation(_)));
}
#[test]
fn complete_task() -> anyhow::Result<()> {
let work_properties = WorkPropertiesInitBuilder::default()
.with_default_availability_status()
.build()?;
// Init state
let mut task = WorkPropertiesUpdateTask::new(context(), work_properties.into());
assert_matches!(&task.state, State::Init(_));
// Challenge state
let instruction = task.poll()?;
assert_matches!(task.state, State::Challenge(_));
assert_matches!(instruction, WorkPropertiesUpdateLoop::Instruction(_));
// Update state
task.response(WorkPropertiesUpdateResponse {
result: Ok(HttpsResponse {
status: 200,
body: serde_json::to_vec(&json!({
"challengePublicKey": "BAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=",
"challenge": "bWVvdw==",
}))?,
}),
})?;
let instruction = task.poll()?;
assert_matches!(task.state, State::Update(_));
assert_matches!(instruction, WorkPropertiesUpdateLoop::Instruction(_));
// Done state
task.response(WorkPropertiesUpdateResponse {
result: Ok(HttpsResponse {
status: 204,
body: vec![],
}),
})?;
let instruction = task.poll()?;
assert_matches!(task.state, State::Done);
assert_matches!(instruction, WorkPropertiesUpdateLoop::Done(()));
Ok(())
}
#[test]
fn truncate_description_length() {
let work_properties = WorkProperties {
availability_status: Some(WorkAvailabilityStatus::Busy("Ick mach mal Urlaub".repeat(50))),
};
let encoded = protobuf::directory::WorkProperties::from(work_properties);
assert!(
encoded
.availability_status
.is_some_and(|status| status.description.len() <= 256),
);
}
}
¤ Dauer der Verarbeitung: 0.3 Sekunden
(vorverarbeitet am 2026-11-04)
¤
*© Formatika GbR, Deutschland
|
|