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"));
}