wekan-cli 0.11.0

Agent-first, human-friendly command-line client for Wekan
Documentation
use std::{
    fs,
    io::{BufRead, BufReader, Write},
    process::{Command, Stdio},
    sync::mpsc,
    thread,
    time::Duration,
};

use super::{
    BACKUP_FILE_NAME, CONFIG_FILE_NAME, FileProfileStore, MAX_CONFIG_BYTES, Profile,
    ProfileDocument, ProfileStore, ProfileStoreError, canonical_server, load_document,
    profile_directory, replace_with_backup, validate_profile_name,
};

const LOCK_HELPER_DIRECTORY_ENV: &str = "WEKAN_TEST_PROFILE_LOCK_DIRECTORY";
const LOCK_HELPER_READY: &str = "WEKAN_PROFILE_LOCK_READY";

struct TestDirectory(std::path::PathBuf);

impl TestDirectory {
    fn new(label: &str) -> Self {
        let path = std::env::temp_dir().join(format!(
            "wekan-cli-profile-{label}-{}-{}",
            std::process::id(),
            super::TEMP_FILE_COUNTER.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
        ));
        fs::create_dir_all(&path).unwrap();
        Self(path)
    }

    fn path(&self) -> &std::path::Path {
        &self.0
    }
}

impl Drop for TestDirectory {
    fn drop(&mut self) {
        let _ = fs::remove_dir_all(&self.0);
    }
}

fn document() -> ProfileDocument {
    let mut document = ProfileDocument::default();
    document.insert(
        "local".to_owned(),
        Profile::new("http://localhost:3000/".to_owned()),
    );
    document.insert(
        "work".to_owned(),
        Profile::new("https://wekan.example/".to_owned()),
    );
    document.set_active_profile(Some("local".to_owned()));
    document
}

#[test]
fn validates_portable_profile_names() {
    for valid in ["a", "local-dev", "prod.eu_1", &"a".repeat(64)] {
        assert!(validate_profile_name(valid).is_ok(), "{valid}");
    }
    for invalid in ["", "Upper", "-local", "has space", "café", &"a".repeat(65)] {
        assert!(validate_profile_name(invalid).is_err(), "{invalid}");
    }
}

#[test]
fn canonicalizes_server_identity_without_network_permission() {
    assert_eq!(
        canonical_server("https://EXAMPLE.com/wekan").unwrap(),
        "https://example.com/wekan/"
    );
    assert_eq!(
        canonical_server("http://wekan.example").unwrap(),
        "http://wekan.example/"
    );
}

#[test]
fn absent_store_is_empty_and_round_trips_sorted_profiles() {
    let directory = TestDirectory::new("round-trip");
    let store = FileProfileStore::at(directory.path().to_owned());
    assert!(store.read().unwrap().document().is_empty());

    store.lock_mutation().unwrap().save(document()).unwrap();
    let snapshot = store.read().unwrap();
    let names = snapshot
        .document()
        .profiles()
        .keys()
        .map(String::as_str)
        .collect::<Vec<_>>();
    assert_eq!(names, ["local", "work"]);
    assert_eq!(snapshot.document().active_profile(), Some("local"));

    let encoded = fs::read_to_string(directory.path().join(CONFIG_FILE_NAME)).unwrap();
    assert!(encoded.contains("\"version\": 1"));
    assert!(encoded.contains("\"revision\": 1"));
    assert!(!encoded.contains("token"));
}

#[test]
fn rejects_unknown_fields_unsupported_versions_and_oversized_files() {
    let directory = TestDirectory::new("invalid");
    let path = directory.path().join(CONFIG_FILE_NAME);
    fs::write(
        &path,
        r#"{"version":1,"revision":0,"active_profile":null,"profiles":{},"unknown":true}"#,
    )
    .unwrap();
    let store = FileProfileStore::at(directory.path().to_owned());
    assert!(matches!(
        store.read(),
        Err(ProfileStoreError::Deserialize(_))
    ));

    fs::write(
        &path,
        r#"{"version":0,"revision":0,"active_profile":null,"profiles":{}}"#,
    )
    .unwrap();
    assert!(matches!(
        store.read(),
        Err(ProfileStoreError::UnsupportedVersion(0))
    ));

    fs::write(&path, vec![b' '; MAX_CONFIG_BYTES as usize + 1]).unwrap();
    assert!(matches!(store.read(), Err(ProfileStoreError::TooLarge)));
}

#[test]
fn rejects_noncanonical_records_and_missing_active_profiles() {
    let directory = TestDirectory::new("validation");
    let path = directory.path().join(CONFIG_FILE_NAME);
    fs::write(
        &path,
        r#"{"version":1,"revision":0,"active_profile":"missing","profiles":{"local":{"server":"https://example.com"}}}"#,
    )
    .unwrap();
    let store = FileProfileStore::at(directory.path().to_owned());
    assert!(matches!(store.read(), Err(ProfileStoreError::Invalid(_))));
}

#[test]
fn reads_the_backup_after_an_interrupted_replace() {
    let directory = TestDirectory::new("backup");
    fs::write(
        directory.path().join(BACKUP_FILE_NAME),
        r#"{"version":1,"revision":0,"active_profile":"local","profiles":{"local":{"server":"http://localhost:3000/"}}}"#,
    )
    .unwrap();
    let store = FileProfileStore::at(directory.path().to_owned());
    assert_eq!(
        store.read().unwrap().document().active_profile(),
        Some("local")
    );

    let mut mutation = store.lock_mutation().unwrap();
    mutation.save(mutation.document().clone()).unwrap();
    assert!(directory.path().join(CONFIG_FILE_NAME).exists());
    let encoded = fs::read_to_string(directory.path().join(CONFIG_FILE_NAME)).unwrap();
    assert!(encoded.contains("\"version\": 1"));
    assert!(encoded.contains("\"revision\": 1"));
}

#[test]
fn primary_inspection_errors_do_not_fall_back_to_backup() {
    let directory = TestDirectory::new("primary-inspection-error");

    let store = FileProfileStore::at(directory.path().to_owned());
    let mut paths = store.paths().unwrap();
    paths.config = directory.path().join("invalid\0primary");
    fs::write(
        &paths.backup,
        r#"{"version":1,"revision":0,"active_profile":null,"profiles":{}}"#,
    )
    .unwrap();

    assert!(matches!(
        load_document(&paths),
        Err(ProfileStoreError::Inspect(_))
    ));
}

#[test]
fn backup_inspection_errors_do_not_make_store_empty() {
    let directory = TestDirectory::new("backup-inspection-error");

    let store = FileProfileStore::at(directory.path().to_owned());
    let mut paths = store.paths().unwrap();
    paths.backup = directory.path().join("invalid\0backup");

    assert!(matches!(
        load_document(&paths),
        Err(ProfileStoreError::Inspect(_))
    ));
}

#[cfg(unix)]
#[test]
fn dangling_primary_link_does_not_fall_back_to_backup() {
    use std::os::unix::fs::symlink;

    let directory = TestDirectory::new("dangling-primary-link");
    let store = FileProfileStore::at(directory.path().to_owned());
    let paths = store.paths().unwrap();
    symlink("missing-profile-document", &paths.config).unwrap();
    fs::write(
        &paths.backup,
        r#"{"version":1,"revision":0,"active_profile":null,"profiles":{}}"#,
    )
    .unwrap();

    assert!(matches!(
        load_document(&paths),
        Err(ProfileStoreError::Inspect(_))
    ));
}

#[test]
fn failed_recovery_replace_preserves_the_only_backup() {
    let directory = TestDirectory::new("backup-failure");
    fs::write(
        directory.path().join(BACKUP_FILE_NAME),
        r#"{"version":1,"revision":0,"active_profile":"local","profiles":{"local":{"server":"http://localhost:3000/"}}}"#,
    )
    .unwrap();
    let store = FileProfileStore::at(directory.path().to_owned());
    let paths = store.paths().unwrap();

    assert!(replace_with_backup(&paths, &directory.path().join("missing.tmp")).is_err());
    assert!(paths.backup.exists());
    assert_eq!(
        store.read().unwrap().document().active_profile(),
        Some("local")
    );
}

#[cfg(unix)]
#[test]
fn replacement_preserves_backup_when_primary_cannot_be_inspected() {
    use std::os::unix::fs::symlink;

    let directory = TestDirectory::new("replace-inspection-error");
    let store = FileProfileStore::at(directory.path().to_owned());
    let paths = store.paths().unwrap();
    symlink(CONFIG_FILE_NAME, &paths.config).unwrap();
    fs::write(&paths.backup, "previous backup").unwrap();
    let temp_path = directory.path().join("replacement.tmp");
    fs::write(&temp_path, "replacement").unwrap();

    assert!(replace_with_backup(&paths, &temp_path).is_err());
    assert!(
        fs::symlink_metadata(&paths.config)
            .unwrap()
            .file_type()
            .is_symlink()
    );
    assert_eq!(
        fs::read_to_string(&paths.backup).unwrap(),
        "previous backup"
    );
    assert_eq!(fs::read_to_string(&temp_path).unwrap(), "replacement");
}

#[test]
fn shared_snapshot_blocks_an_exclusive_mutation() {
    let directory = TestDirectory::new("thread-lock");
    let store = FileProfileStore::at(directory.path().to_owned());
    let snapshot = store.read().unwrap();
    let other_store = store.clone();
    let (sender, receiver) = mpsc::channel();
    let worker = thread::spawn(move || {
        let _mutation = other_store.lock_mutation().unwrap();
        sender.send(()).unwrap();
    });
    assert!(receiver.recv_timeout(Duration::from_millis(150)).is_err());
    drop(snapshot);
    receiver.recv_timeout(Duration::from_secs(2)).unwrap();
    worker.join().unwrap();
}

#[test]
fn config_directory_override_must_be_absolute() {
    assert!(profile_directory(Some("relative".into())).is_err());
    let directory = TestDirectory::new("override");
    assert_eq!(
        profile_directory(Some(directory.path().as_os_str().to_owned())).unwrap(),
        directory.path()
    );
}

#[test]
fn persistent_profile_stores_have_stable_distinct_credential_namespaces() {
    let first_directory = TestDirectory::new("credential-namespace-first");
    let second_directory = TestDirectory::new("credential-namespace-second");
    let first = FileProfileStore::at(first_directory.path().to_owned());
    let second = FileProfileStore::at(second_directory.path().to_owned());

    let first_namespace = first.credential_namespace().unwrap();
    assert_eq!(first.credential_namespace().unwrap(), first_namespace);
    assert_ne!(first_namespace, second.credential_namespace().unwrap());
}

#[cfg(unix)]
#[test]
fn store_uses_private_unix_permissions_for_created_paths() {
    use std::os::unix::fs::PermissionsExt;

    let root = TestDirectory::new("permissions");
    let directory = root.path().join("created");
    let store = FileProfileStore::at(directory.clone());
    store.lock_mutation().unwrap().save(document()).unwrap();
    assert_eq!(
        fs::metadata(&directory).unwrap().permissions().mode() & 0o777,
        0o700
    );
    assert_eq!(
        fs::metadata(directory.join(CONFIG_FILE_NAME))
            .unwrap()
            .permissions()
            .mode()
            & 0o777,
        0o600
    );
}

#[cfg(unix)]
#[test]
fn store_preserves_preexisting_unix_directory_permissions() {
    use std::os::unix::fs::PermissionsExt;

    let directory = TestDirectory::new("existing-permissions");
    fs::set_permissions(directory.path(), fs::Permissions::from_mode(0o750)).unwrap();
    let store = FileProfileStore::at(directory.path().to_owned());
    drop(store.read().unwrap());

    assert_eq!(
        fs::metadata(directory.path()).unwrap().permissions().mode() & 0o777,
        0o750
    );
}

#[test]
#[ignore = "subprocess helper invoked by the cross-process profile lock test"]
fn profile_lock_process_helper() {
    let Some(directory) = std::env::var_os(LOCK_HELPER_DIRECTORY_ENV) else {
        return;
    };
    let store = FileProfileStore::at(directory.into());
    let _mutation = store.lock_mutation().unwrap();
    println!("{LOCK_HELPER_READY}");
    std::io::stdout().flush().unwrap();
}

#[test]
fn profile_lock_excludes_another_process() {
    let directory = TestDirectory::new("process-lock");
    let store = FileProfileStore::at(directory.path().to_owned());
    let snapshot = store.read().unwrap();
    let mut child = Command::new(std::env::current_exe().unwrap())
        .args([
            "--exact",
            "config::profiles::tests::profile_lock_process_helper",
            "--ignored",
            "--nocapture",
        ])
        .env(LOCK_HELPER_DIRECTORY_ENV, directory.path())
        .stdout(Stdio::piped())
        .spawn()
        .unwrap();
    let stdout = child.stdout.take().unwrap();
    let (sender, receiver) = mpsc::channel();
    thread::spawn(move || {
        let mut reader = BufReader::new(stdout);
        let mut sender = Some(sender);
        loop {
            let mut line = String::new();
            if reader.read_line(&mut line).unwrap() == 0 {
                return;
            }
            if line.contains(LOCK_HELPER_READY) {
                if let Some(sender) = sender.take() {
                    sender.send(line).unwrap();
                }
            }
        }
    });
    assert!(receiver.recv_timeout(Duration::from_millis(200)).is_err());
    drop(snapshot);
    assert!(
        receiver
            .recv_timeout(Duration::from_secs(2))
            .unwrap()
            .contains(LOCK_HELPER_READY)
    );
    assert!(child.wait().unwrap().success());
}