wekan-cli 0.12.0

Agent-first, human-friendly command-line client for Wekan
Documentation
use std::fs;

use super::{
    MissingProfileResolution, ResolvedTarget, ServerEnvironment, ServerSelection, TargetResolver,
};
use crate::{
    config::profiles::{
        FileProfileStore, Profile, ProfileDocument, ProfileMutation, ProfileSnapshot, ProfileStore,
        ProfileStoreError,
    },
    error::{AppError, ErrorCode},
};

struct StaticProfileStore(ProfileDocument);

impl ProfileStore for StaticProfileStore {
    fn read(&self) -> Result<ProfileSnapshot, ProfileStoreError> {
        Ok(ProfileSnapshot::new_unlocked(self.0.clone()))
    }

    fn lock_mutation(&self) -> Result<Box<dyn ProfileMutation + Send + '_>, ProfileStoreError> {
        panic!("target resolution must not mutate profiles")
    }

    fn credential_namespace(&self) -> Result<String, ProfileStoreError> {
        Ok("test-store".to_owned())
    }
}

fn profiles() -> StaticProfileStore {
    let mut document = ProfileDocument::default();
    document.insert(
        "active".to_owned(),
        Profile::new("https://active.example/".to_owned()),
    );
    document.insert(
        "explicit".to_owned(),
        Profile::new("https://explicit.example/".to_owned()),
    );
    document.insert(
        "environment".to_owned(),
        Profile::new("https://environment.example/".to_owned()),
    );
    document.set_active_profile(Some("active".to_owned()));
    StaticProfileStore(document)
}

fn resolve<'a>(
    selection: ServerSelection,
    profile_store: &'a dyn ProfileStore,
    environment_server: Option<&str>,
    environment_profile: Option<&str>,
    missing_profile_resolution: MissingProfileResolution,
) -> Result<ResolvedTarget<'a>, AppError> {
    TargetResolver::new(selection).resolve_with_environment(
        profile_store,
        ServerEnvironment {
            server: environment_server.map(str::to_owned),
            profile: environment_profile.map(str::to_owned),
        },
        missing_profile_resolution,
    )
}

#[test]
fn profile_and_server_sources_resolve_independently_by_precedence() {
    let store = profiles();
    let target = resolve(
        ServerSelection {
            explicit_server: Some("https://explicit.example".to_owned()),
            explicit_profile: Some("explicit".to_owned()),
            ..ServerSelection::default()
        },
        &store,
        Some("not a URL and ignored"),
        Some("INVALID AND IGNORED"),
        MissingProfileResolution::Reject,
    )
    .unwrap();
    assert_eq!(
        target.client_factory().server_identity().as_str(),
        "https://explicit.example/"
    );
    assert_eq!(target.profile(), "explicit");
}

#[test]
fn environment_profile_and_url_override_the_active_profile_together() {
    let store = profiles();
    let target = resolve(
        ServerSelection::default(),
        &store,
        Some("https://environment.example"),
        Some("environment"),
        MissingProfileResolution::Reject,
    )
    .unwrap();
    assert_eq!(target.profile(), "environment");
}

#[test]
fn active_profile_is_the_final_fallback() {
    let store = profiles();
    let target = resolve(
        ServerSelection::default(),
        &store,
        None,
        None,
        MissingProfileResolution::Reject,
    )
    .unwrap();
    assert_eq!(target.profile(), "active");
}

#[test]
fn missing_selection_falls_back_to_default_and_requires_initializable_auth() {
    let empty = StaticProfileStore(ProfileDocument::default());
    let missing = resolve(
        ServerSelection::default(),
        &empty,
        Some("https://wekan.example"),
        None,
        MissingProfileResolution::Reject,
    )
    .err()
    .unwrap();
    assert_eq!(missing.code(), ErrorCode::ProfileNotFound);
    assert_eq!(missing.details().profile.as_deref(), Some("default"));

    let missing = resolve(
        ServerSelection {
            explicit_profile: Some("missing".to_owned()),
            ..ServerSelection::default()
        },
        &profiles(),
        None,
        None,
        MissingProfileResolution::Initialize,
    )
    .err()
    .unwrap();
    assert_eq!(missing.code(), ErrorCode::ProfileNotFound);
    assert_eq!(missing.details().profile.as_deref(), Some("missing"));
}

#[test]
fn supplied_server_must_match_an_existing_profile_without_mutating_it() {
    let error = resolve(
        ServerSelection {
            explicit_server: Some("https://other.example".to_owned()),
            explicit_profile: Some("explicit".to_owned()),
            ..ServerSelection::default()
        },
        &profiles(),
        None,
        None,
        MissingProfileResolution::Initialize,
    )
    .err()
    .unwrap();
    assert_eq!(error.code(), ErrorCode::ProfileServerMismatch);
    assert_eq!(error.details().profile.as_deref(), Some("explicit"));
}

#[test]
fn successful_initialization_persists_default_and_activates_the_first_profile() {
    let directory =
        std::env::temp_dir().join(format!("wekan-cli-resolver-init-{}", std::process::id()));
    let _ = fs::remove_dir_all(&directory);
    fs::create_dir_all(&directory).unwrap();
    let store = FileProfileStore::at(directory.clone());
    let mut target = resolve(
        ServerSelection::default(),
        &store,
        Some("https://wekan.example"),
        None,
        MissingProfileResolution::Initialize,
    )
    .unwrap();
    assert_eq!(target.profile(), "default");
    assert!(!target.profile_created());
    target.initialize_profile().unwrap();
    assert!(target.profile_created());
    assert!(target.profile_active());
    drop(target);
    let snapshot = store.read().unwrap();
    assert_eq!(snapshot.document().active_profile(), Some("default"));
    assert_eq!(
        snapshot.document().profile("default").unwrap().server(),
        "https://wekan.example/"
    );
    drop(snapshot);
    fs::remove_dir_all(directory).unwrap();
}

#[test]
fn missing_profile_local_credential_cleanup_uses_the_supplied_server_without_mutation() {
    let empty = StaticProfileStore(ProfileDocument::default());
    let target = resolve(
        ServerSelection {
            explicit_profile: Some("orphaned".to_owned()),
            ..ServerSelection::default()
        },
        &empty,
        Some("https://wekan.example"),
        None,
        MissingProfileResolution::LocalCredentialCleanup,
    )
    .unwrap();

    assert_eq!(target.profile(), "orphaned");
    assert_eq!(
        target.client_factory().server_identity().as_str(),
        "https://wekan.example/"
    );
    assert!(!target.profile_created());
    assert!(!target.profile_active());

    let error = match resolve(
        ServerSelection {
            explicit_profile: Some("orphaned".to_owned()),
            ..ServerSelection::default()
        },
        &empty,
        None,
        None,
        MissingProfileResolution::LocalCredentialCleanup,
    ) {
        Ok(_) => panic!("a missing profile without a supplied server must be rejected"),
        Err(error) => error,
    };
    assert_eq!(error.code(), ErrorCode::ProfileNotFound);
    assert_eq!(error.details().profile.as_deref(), Some("orphaned"));
}

#[test]
fn resolving_remote_http_does_not_grant_network_permission() {
    let mut document = ProfileDocument::default();
    document.insert(
        "remote".to_owned(),
        Profile::new("http://wekan.example/".to_owned()),
    );
    let profiles = StaticProfileStore(document);

    let target = resolve(
        ServerSelection {
            explicit_profile: Some("remote".to_owned()),
            ..ServerSelection::default()
        },
        &profiles,
        None,
        None,
        MissingProfileResolution::Reject,
    )
    .unwrap();
    assert!(target.client_factory().create().is_err());

    let target = resolve(
        ServerSelection {
            explicit_profile: Some("remote".to_owned()),
            allow_insecure_http: true,
            ..ServerSelection::default()
        },
        &profiles,
        None,
        None,
        MissingProfileResolution::Reject,
    )
    .unwrap();
    assert!(target.client_factory().create().is_ok());
}