wekan-cli 0.11.0

Agent-first, human-friendly command-line client for Wekan
Documentation
use crate::{
    cli::Cli,
    client::WekanClientFactory,
    command_result::{CommandSuccess, DestructiveOperation},
    commands::RootCommand,
    credentials::{
        CredentialCreateOutcome, CredentialDeleteOutcome, CredentialError, CredentialRecord,
        CredentialStore, CredentialTarget,
    },
    error::{AppError, ErrorCode},
    input::{ConfirmationProvider, ConfirmationRequest, FakeConfirmationProvider},
    output::{OutputFormat, write_success},
};
use clap::Parser;
use secrecy::SecretString;
use serde_json::{Value, json};
use std::sync::Mutex;
use time::{Duration, OffsetDateTime};
use wiremock::{
    Mock, MockServer, ResponseTemplate,
    matchers::{body_json, method, path},
};

struct Store {
    record: Mutex<Option<CredentialRecord>>,
    change_on_load: bool,
    loads: std::sync::atomic::AtomicUsize,
}
impl Store {
    fn new(server: &MockServer) -> Self {
        Self {
            record: Mutex::new(Some(CredentialRecord::new(
                format!("{}/", server.uri()),
                "user".into(),
                SecretString::from("attachment-token".to_owned()),
                OffsetDateTime::now_utc() + Duration::hours(1),
            ))),
            change_on_load: false,
            loads: std::sync::atomic::AtomicUsize::new(0),
        }
    }
}
impl CredentialStore for Store {
    fn check_available(&self, _: &CredentialTarget) -> Result<(), CredentialError> {
        Ok(())
    }
    fn exists(&self, _: &CredentialTarget) -> Result<bool, CredentialError> {
        Ok(true)
    }
    fn load(&self, _: &CredentialTarget) -> Result<Option<CredentialRecord>, CredentialError> {
        if self.change_on_load && self.loads.fetch_add(1, std::sync::atomic::Ordering::SeqCst) > 0 {
            let previous = self.record.lock().unwrap().clone().unwrap();
            return Ok(Some(CredentialRecord::new(
                previous.server_url().into(),
                "different-user".into(),
                SecretString::from("different-token".to_owned()),
                previous.token_expires(),
            )));
        }
        Ok(self.record.lock().unwrap().clone())
    }
    fn create(
        &self,
        _: &CredentialTarget,
        _: &CredentialRecord,
    ) -> Result<CredentialCreateOutcome, CredentialError> {
        panic!("attachment commands must not change credentials")
    }
    fn delete(&self, _: &CredentialTarget) -> Result<bool, CredentialError> {
        panic!("attachment commands must not change credentials")
    }
    fn delete_if_matches(
        &self,
        _: &CredentialTarget,
        _: &CredentialRecord,
    ) -> Result<CredentialDeleteOutcome, CredentialError> {
        panic!("attachment commands must not change credentials")
    }
}
async fn run(
    server: &MockServer,
    store: &Store,
    args: &[&str],
    confirmation: &dyn ConfirmationProvider,
) -> Result<CommandSuccess, AppError> {
    let mut argv = vec!["wekan", "attachment"];
    argv.extend(args);
    let RootCommand::Attachment(a) = Cli::try_parse_from(argv).unwrap().command else {
        unreachable!()
    };
    super::dispatch(
        a.command,
        &WekanClientFactory::for_profile(format!("{}/", server.uri()), "default".into(), false),
        store,
        confirmation,
    )
    .await
}
fn download_body() -> Value {
    json!({"success":true,"attachmentId":"a","fileName":"unsafe\nname","fileSize":3,"fileType":"application/octet-stream","storageBackend":"fs","base64Data":"AP8K"})
}

#[tokio::test]
async fn deletion_cancels_and_rejects_changed_credentials_without_http() {
    let server = MockServer::start().await;
    let confirmation = FakeConfirmationProvider::declining();
    let result = run(
        &server,
        &Store::new(&server),
        &["delete", "a"],
        &confirmation,
    )
    .await
    .unwrap();
    let CommandSuccess::Cancelled(result) = result else {
        panic!("expected cancellation")
    };
    assert_eq!(result.operation, DestructiveOperation::AttachmentDelete);
    assert!(confirmation.requests()[0].prompt().contains("stored file"));
    let mut store = Store::new(&server);
    store.change_on_load = true;
    let error = run(&server, &store, &["delete", "a", "--yes"], &confirmation)
        .await
        .unwrap_err();
    assert_eq!(error.code(), ErrorCode::CredentialStoreFailed);
    assert!(server.received_requests().await.unwrap().is_empty());
}

struct ChangingConfirmation<'a> {
    store: &'a Store,
    replacement: Option<CredentialRecord>,
}

impl ConfirmationProvider for ChangingConfirmation<'_> {
    fn confirm(&self, _: &ConfirmationRequest) -> Result<bool, AppError> {
        *self.store.record.lock().unwrap() = self.replacement.clone();
        Ok(true)
    }
}

#[tokio::test]
async fn deletion_rechecks_replacement_and_removal_after_confirmation() {
    for removed in [false, true] {
        let server = MockServer::start().await;
        let store = Store::new(&server);
        let replacement = (!removed).then(|| {
            CredentialRecord::new(
                format!("{}/", server.uri()),
                "another-user".into(),
                SecretString::from("another-token".to_owned()),
                OffsetDateTime::now_utc() + Duration::hours(1),
            )
        });
        let confirmation = ChangingConfirmation {
            store: &store,
            replacement,
        };
        let error = run(&server, &store, &["delete", "a"], &confirmation)
            .await
            .unwrap_err();
        assert_eq!(
            error.code(),
            if removed {
                ErrorCode::CredentialNotFound
            } else {
                ErrorCode::CredentialStoreFailed
            }
        );
        assert_eq!(
            error.exit_code(),
            crate::exit_code::StableExitCode::Credential
        );
        assert!(error.message().contains("no HTTP request was sent"));
        assert!(server.received_requests().await.unwrap().is_empty());
    }
}

#[tokio::test]
async fn deletion_rechecks_expiry_after_confirmation() {
    struct ExpiringConfirmation {
        expires: OffsetDateTime,
        called: std::sync::atomic::AtomicBool,
    }
    impl ConfirmationProvider for ExpiringConfirmation {
        fn confirm(&self, _: &ConfirmationRequest) -> Result<bool, AppError> {
            self.called.store(true, std::sync::atomic::Ordering::SeqCst);
            while OffsetDateTime::now_utc() < self.expires {
                std::thread::sleep(std::time::Duration::from_millis(10));
            }
            Ok(true)
        }
    }
    let server = MockServer::start().await;
    let store = Store::new(&server);
    let expires = OffsetDateTime::now_utc() + Duration::seconds(2);
    *store.record.lock().unwrap() = Some(CredentialRecord::new(
        format!("{}/", server.uri()),
        "user".into(),
        SecretString::from("attachment-token".to_owned()),
        expires,
    ));
    let confirmation = ExpiringConfirmation {
        expires,
        called: std::sync::atomic::AtomicBool::new(false),
    };
    let error = run(&server, &store, &["delete", "a"], &confirmation)
        .await
        .unwrap_err();
    assert!(
        confirmation
            .called
            .load(std::sync::atomic::Ordering::SeqCst)
    );
    assert_eq!(error.code(), ErrorCode::CredentialExpired);
    assert_eq!(error.exit_code(), crate::exit_code::StableExitCode::Server);
    assert_eq!(
        error.details().token_expires.as_deref(),
        Some(
            expires
                .format(&time::format_description::well_known::Rfc3339)
                .unwrap()
                .as_str()
        )
    );
    assert!(server.received_requests().await.unwrap().is_empty());
}

#[tokio::test]
async fn downloads_validate_before_saving_and_preserve_completed_file_context() {
    let server = MockServer::start().await;
    let store = Store::new(&server);
    let confirmation = FakeConfirmationProvider::declining();
    let directory = tempfile::tempdir().unwrap();
    let file = directory.path().join("attachment.bin");
    let destination = file.to_str().unwrap();
    std::fs::write(&file, b"old").unwrap();
    let mut invalid = download_body();
    invalid["future"] = json!(true);
    Mock::given(method("GET"))
        .respond_with(ResponseTemplate::new(200).set_body_json(invalid))
        .mount(&server)
        .await;
    assert_eq!(
        run(
            &server,
            &store,
            &["download", "a", "--destination", destination, "--overwrite"],
            &confirmation
        )
        .await
        .unwrap_err()
        .code(),
        ErrorCode::ProtocolError
    );
    assert_eq!(std::fs::read(&file).unwrap(), b"old");
    server.reset().await;
    Mock::given(method("GET"))
        .respond_with(ResponseTemplate::new(200).set_body_json(download_body()))
        .mount(&server)
        .await;
    assert_eq!(
        run(
            &server,
            &store,
            &["download", "a", "--destination", destination],
            &confirmation
        )
        .await
        .unwrap_err()
        .code(),
        ErrorCode::FileIoError
    );
    let result = run(
        &server,
        &store,
        &["download", "a", "--destination", destination, "--overwrite"],
        &confirmation,
    )
    .await
    .unwrap();
    assert_eq!(std::fs::read(&file).unwrap(), [0, 255, 10]);
    struct BrokenWriter;
    impl std::io::Write for BrokenWriter {
        fn write(&mut self, _: &[u8]) -> std::io::Result<usize> {
            Err(std::io::Error::other("output failure"))
        }
        fn flush(&mut self) -> std::io::Result<()> {
            Ok(())
        }
    }
    let error = write_success(
        OutputFormat::Json,
        result,
        &mut BrokenWriter,
        &mut Vec::new(),
    )
    .await
    .unwrap_err();
    assert_eq!(error.details().file_saved, Some(true));
    assert_eq!(error.details().destination.as_deref(), Some(destination));
    let result = run(
        &server,
        &store,
        &["download", "a", "--destination", "-"],
        &confirmation,
    )
    .await
    .unwrap();
    let mut stdout = Vec::new();
    let mut stderr = Vec::new();
    write_success(OutputFormat::Raw, result, &mut stdout, &mut stderr)
        .await
        .unwrap();
    assert_eq!(stdout, [0, 255, 10]);
    assert!(stderr.is_empty());
}

#[tokio::test]
async fn upload_preserves_binary_and_explicit_names_and_reports_acknowledgement() {
    let server = MockServer::start().await;
    let store = Store::new(&server);
    let directory = tempfile::tempdir().unwrap();
    let file = directory.path().join("source.bin");
    std::fs::write(&file, [0, 255, 10]).unwrap();
    Mock::given(method("POST")).and(path("/api/attachment/upload-background"))
        .and(body_json(json!({"boardId":"b","fileName":"  named.png  ","fileType":"image/png","fileData":"AP8K"})))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({"success":true,"attachmentId":"a","fileName":"named.png","fileSize":3,"storageBackend":"fs","message":"Uploaded","backgroundImageURL":"/background"}))).expect(1).mount(&server).await;
    let result = run(
        &server,
        &store,
        &[
            "upload-background",
            file.to_str().unwrap(),
            "--board",
            "b",
            "--file-name",
            "  named.png  ",
            "--file-type",
            "image/png",
        ],
        &FakeConfirmationProvider::declining(),
    )
    .await
    .unwrap();
    let output: Value =
        serde_json::from_str(&crate::output::render_success(OutputFormat::Json, &result)).unwrap();
    assert_eq!(output["data"]["mutation_confirmed"], false);
    assert_eq!(output["data"]["background_image_url"], "/background");
    server.reset().await;
    Mock::given(method("POST"))
        .respond_with(
            ResponseTemplate::new(408)
                .set_body_json(json!({"success":false,"error":"Request timeout"})),
        )
        .expect(1)
        .mount(&server)
        .await;
    let error = run(
        &server,
        &store,
        &["upload-background", file.to_str().unwrap(), "--board", "b"],
        &FakeConfirmationProvider::declining(),
    )
    .await
    .unwrap_err();
    assert_eq!(error.details().http_status, Some(408));
    assert!(error.details().outcome_unknown.is_none());
}

#[tokio::test]
async fn scoped_listing_exposes_board_scope_without_filtering() {
    let server = MockServer::start().await;
    let store = Store::new(&server);
    Mock::given(method("GET")).and(path("/api/attachment/list/b/s/l/c"))
        .respond_with(ResponseTemplate::new(200).set_body_json(json!({"success":true,"count":1,"attachments":[{"attachmentId":"other","fileName":"another","fileSize":0,"fileType":"text/plain","storageBackend":"fs","boardId":"b","cardId":"other-card"}]}))).mount(&server).await;
    let result = run(
        &server,
        &store,
        &[
            "list",
            "--board",
            "b",
            "--swimlane-id",
            "s",
            "--list",
            "l",
            "--card",
            "c",
        ],
        &FakeConfirmationProvider::declining(),
    )
    .await
    .unwrap();
    let output: Value =
        serde_json::from_str(&crate::output::render_success(OutputFormat::Json, &result)).unwrap();
    assert_eq!(output["data"]["requested_scope"], "card");
    assert_eq!(output["data"]["effective_scope"], "board");
    assert_eq!(output["data"]["attachments"][0]["card_id"], "other-card");
    assert!(crate::output::render_success(OutputFormat::Human, &result).contains("ignores"));
}