diff --git a/src/integration_tests/carddav/addressbook.rs b/src/integration_tests/carddav/addressbook.rs
new file mode 100644
index 0000000..9d7ca45
--- /dev/null
+++ b/src/integration_tests/carddav/addressbook.rs
@@ -0,0 +1,194 @@
+use crate::integration_tests::{ResponseExtractString, get_app};
+use axum::body::Body;
+use axum::extract::Request;
+use headers::{Authorization, HeaderMapExt};
+use http::{HeaderValue, StatusCode};
+use rstest::rstest;
+use rustical_store::{Addressbook, AddressbookStore};
+use rustical_store_sqlite::tests::{TestStoreContext, test_store_context};
+use tower::ServiceExt;
+
+fn mkcol_template(displayname: &str, description: &str) -> String {
+ format!(
+ r#"
+
+
+
+
+
+
+
+
+ {displayname}
+ {description}
+
+
+
+ "#,
+ )
+}
+
+#[rstest]
+#[tokio::test]
+async fn test_caldav_calendar(
+ #[from(test_store_context)]
+ #[future]
+ context: TestStoreContext,
+) {
+ let context = context.await;
+ let app = get_app(context.clone());
+ let addr_store = context.addr_store;
+
+ let (mut displayname, mut description) = (
+ Some("Contacts".to_owned()),
+ Some("Amazing contacts!".to_owned()),
+ );
+ let (principal, addr_id) = ("user", "contacts");
+ let url = format!("/carddav/principal/{principal}/{addr_id}");
+
+ let request_template = || {
+ Request::builder()
+ .method("MKCALENDAR")
+ .uri(&url)
+ .body(Body::from(mkcol_template(
+ displayname.as_ref().unwrap(),
+ description.as_ref().unwrap(),
+ )))
+ .unwrap()
+ };
+
+ // Try OPTIONS without authentication
+ let request = Request::builder()
+ .method("OPTIONS")
+ .uri(&url)
+ .body(Body::empty())
+ .unwrap();
+ let response = app.clone().oneshot(request).await.unwrap();
+ insta::assert_debug_snapshot!(response, @r#"
+ Response {
+ status: 200,
+ version: HTTP/1.1,
+ headers: {
+ "dav": "1, 3, access-control, addressbook, webdav-push",
+ "allow": "PROPFIND, PROPPATCH, COPY, MOVE, DELETE, OPTIONS, REPORT, GET, HEAD, POST, MKCOL, IMPORT",
+ },
+ body: Body(
+ UnsyncBoxBody,
+ ),
+ }
+ "#);
+
+ // Try without authentication
+ let request = request_template();
+ let response = app.clone().oneshot(request).await.unwrap();
+ assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
+
+ // Try with correct credentials
+ let mut request = request_template();
+ request
+ .headers_mut()
+ .typed_insert(Authorization::basic("user", "pass"));
+ let response = app.clone().oneshot(request).await.unwrap();
+ assert_eq!(response.status(), StatusCode::CREATED);
+ let body = response.extract_string().await;
+ insta::assert_snapshot!("mkcol_body", body);
+
+ let mut request = Request::builder()
+ .method("GET")
+ .uri(&url)
+ .body(Body::empty())
+ .unwrap();
+ request
+ .headers_mut()
+ .typed_insert(Authorization::basic("user", "pass"));
+ let response = app.clone().oneshot(request).await.unwrap();
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = response.extract_string().await;
+ insta::assert_snapshot!("get_body", body);
+
+ let saved_addressbook = addr_store
+ .get_addressbook(principal, addr_id, false)
+ .await
+ .unwrap();
+ assert_eq!(
+ (saved_addressbook.displayname, saved_addressbook.description),
+ (displayname, description)
+ );
+
+ let mut request = Request::builder()
+ .method("PROPFIND")
+ .uri(&url)
+ .body(Body::empty())
+ .unwrap();
+ request
+ .headers_mut()
+ .typed_insert(Authorization::basic("user", "pass"));
+ let response = app.clone().oneshot(request).await.unwrap();
+ assert_eq!(response.status(), StatusCode::MULTI_STATUS);
+ let body = response.extract_string().await;
+ insta::with_settings!({
+ filters => vec![
+ (r"[0-9a-f-]+", "[PUSH_TOPIC]")
+ ]
+ }, {
+ insta::assert_snapshot!("propfind_body", body);
+ });
+
+ let proppatch_request: &str = r#"
+
+
+
+ New Displayname
+ Test
+
+
+
+
+
+
+
+
+ "#;
+ let mut request = Request::builder()
+ .method("PROPPATCH")
+ .uri(&url)
+ .body(Body::from(proppatch_request))
+ .unwrap();
+ request
+ .headers_mut()
+ .typed_insert(Authorization::basic("user", "pass"));
+ let response = app.clone().oneshot(request).await.unwrap();
+ assert_eq!(response.status(), StatusCode::MULTI_STATUS);
+ let body = response.extract_string().await;
+ insta::assert_snapshot!("proppatch_body", body);
+
+ displayname = Some("New Displayname".to_string());
+ description = None;
+ let saved_addressbook = addr_store
+ .get_addressbook(principal, addr_id, false)
+ .await
+ .unwrap();
+ assert_eq!(
+ (saved_addressbook.displayname, saved_addressbook.description),
+ (displayname, description)
+ );
+
+ let mut request = Request::builder()
+ .method("DELETE")
+ .uri(&url)
+ .header("X-No-Trashbin", HeaderValue::from_static("1"))
+ .body(Body::empty())
+ .unwrap();
+ request
+ .headers_mut()
+ .typed_insert(Authorization::basic("user", "pass"));
+ let response = app.clone().oneshot(request).await.unwrap();
+ assert_eq!(response.status(), StatusCode::OK);
+ let body = response.extract_string().await;
+ insta::assert_snapshot!("delete_body", body);
+
+ assert!(matches!(
+ addr_store.get_addressbook(principal, addr_id, false).await,
+ Err(rustical_store::Error::NotFound)
+ ));
+}
diff --git a/src/integration_tests/carddav/mod.rs b/src/integration_tests/carddav/mod.rs
index d55ae82..520d11f 100644
--- a/src/integration_tests/carddav/mod.rs
+++ b/src/integration_tests/carddav/mod.rs
@@ -7,6 +7,8 @@ use rstest::rstest;
use rustical_store_sqlite::tests::{TestStoreContext, test_store_context};
use tower::ServiceExt;
+mod addressbook;
+
#[rstest]
#[tokio::test]
async fn test_carddav_root(