caps and delete

This commit is contained in:
2025-11-05 12:32:10 +01:00
parent 4ec19dbd70
commit 480bc20ae7
30 changed files with 2702 additions and 262 deletions
+107 -9
View File
@@ -7,7 +7,7 @@ use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine;
use common::{acquire_db_lock, body_to_vec, TestApp};
use diesel::prelude::*;
use papercrate::models::{ApiToken, ApiTokenCapability};
use papercrate::models::{ApiCapability, ApiToken};
use papercrate::routes::webdav;
use papercrate::schema::api_tokens;
use serde::Deserialize;
@@ -15,13 +15,46 @@ use serde_json::json;
use tower::ServiceExt;
use uuid::Uuid;
const LEGACY_WEBDAV_CAPS: &[&str] = &[
"documents:edit",
"documents:read",
"documents:upload",
"documents:write",
"folders:edit",
"folders:read",
"folders:write",
"webdav:read",
];
const OWNER_CAPS: &[&str] = &[
"correspondents:edit",
"correspondents:read",
"correspondents:write",
"documents:edit",
"documents:read",
"documents:upload",
"documents:write",
"folders:edit",
"folders:read",
"folders:write",
"profile:read",
"profile:write",
"tags:edit",
"tags:read",
"tags:write",
"webdav:read",
"webdav:write",
"capability_sets:read",
"capability_sets:write",
];
#[derive(Debug, Deserialize)]
struct TokenInfo {
id: Uuid,
label: Option<String>,
last_used_at: Option<String>,
revoked_at: Option<String>,
capabilities: Vec<ApiTokenCapability>,
capabilities: Vec<ApiCapability>,
}
#[derive(Debug, Deserialize)]
@@ -55,11 +88,16 @@ async fn api_token_crud_flow() -> Result<()> {
app.insert_user(username, password, "admin").await?;
let access_token = app.login_token(username, password).await?;
let created = create_token(&app, &access_token, json!({ "label": "dav" })).await?;
let created = create_token(
&app,
&access_token,
json!({ "label": "dav", "capabilities": LEGACY_WEBDAV_CAPS }),
)
.await?;
let token_id = created.info.id;
assert_eq!(created.info.label.as_deref(), Some("dav"));
assert!(created.info.last_used_at.is_none());
assert_eq!(created.info.capabilities, vec![ApiTokenCapability::Webdav]);
assert_capabilities(&created.info.capabilities, LEGACY_WEBDAV_CAPS);
let regenerated = regenerate_token(&app, &access_token, token_id).await?;
assert_eq!(regenerated.info.id, token_id);
@@ -70,12 +108,10 @@ async fn api_token_crud_flow() -> Result<()> {
&app,
&access_token,
token_id,
json!({ "capabilities": ["webdav", "api"] }),
json!({ "capabilities": OWNER_CAPS }),
)
.await?;
assert_eq!(updated.capabilities.len(), 2);
assert!(updated.capabilities.contains(&ApiTokenCapability::Webdav));
assert!(updated.capabilities.contains(&ApiTokenCapability::Api));
assert_capabilities(&updated.capabilities, OWNER_CAPS);
let listed = list_tokens(&app, &access_token).await?;
assert_eq!(listed.len(), 1);
@@ -119,7 +155,12 @@ async fn webdav_basic_auth_uses_api_tokens() -> Result<()> {
app.insert_user(username, password, "admin").await?;
let access_token = app.login_token(username, password).await?;
let created = create_token(&app, &access_token, json!({ "label": "webdav" })).await?;
let created = create_token(
&app,
&access_token,
json!({ "label": "webdav", "capabilities": LEGACY_WEBDAV_CAPS }),
)
.await?;
let token_id = created.info.id;
let router = webdav::create_router().with_state(app.state.clone());
@@ -187,6 +228,50 @@ async fn webdav_basic_auth_uses_api_tokens() -> Result<()> {
let response = router.clone().oneshot(success_request).await?;
assert_eq!(response.status(), StatusCode::MULTI_STATUS);
// Token without webdav_read cannot authenticate.
let limited_token = create_token(
&app,
&access_token,
json!({
"label": "limited",
"capabilities": ["documents:read"]
}),
)
.await?;
let limited_header = format!(
"Basic {}",
BASE64.encode(format!("{}:{}", username, limited_token.token))
);
let limited_request = Request::builder()
.method(propfind.clone())
.uri("/")
.header(header::AUTHORIZATION, limited_header.clone())
.header("depth", "0")
.body(Body::empty())?;
let limited_response = router.clone().oneshot(limited_request).await?;
assert_eq!(limited_response.status(), StatusCode::UNAUTHORIZED);
let _ = app
.patch_json(
&format!("/api/profile/api-tokens/{}", limited_token.info.id),
&json!({
"capabilities": ["documents:read", "webdav:read"]
}),
Some(&access_token),
)
.await?;
let upgraded_request = Request::builder()
.method(propfind.clone())
.uri("/")
.header(header::AUTHORIZATION, limited_header)
.header("depth", "0")
.body(Body::empty())?;
let upgraded_response = router.clone().oneshot(upgraded_request).await?;
assert_eq!(upgraded_response.status(), StatusCode::MULTI_STATUS);
delete_token(&app, &access_token, token_id).await?;
let failure_request = Request::builder()
@@ -269,6 +354,19 @@ async fn delete_token(app: &TestApp, access_token: &str, token_id: Uuid) -> Resu
assert_eq!(response.status(), StatusCode::NO_CONTENT);
Ok(())
}
fn assert_capabilities(actual: &[ApiCapability], expected: &[&str]) {
assert_eq!(actual.len(), expected.len());
for capability in expected {
assert!(
actual
.iter()
.any(|candidate| candidate.as_str() == *capability),
"capability '{}' not present",
capability
);
}
}
async fn exchange_token(app: &TestApp, api_token: &str) -> Result<LoginResponseView> {
let response = app
.post_json(
+29 -3
View File
@@ -5,13 +5,15 @@ use axum::http::{header::SET_COOKIE, StatusCode};
use chrono::{Duration as ChronoDuration, Utc};
use common::{acquire_db_lock, body_to_vec, TestApp};
use diesel::prelude::*;
use papercrate::auth::capability_sets::{ensure_capability_set, owner_capabilities};
use papercrate::auth::jwt::{AccessTokenContext, PrincipalKind};
use papercrate::auth::passkeys::{
PasskeyLoginFinishPayload, PasskeyLoginStartPayload, PasskeyRegistrationFinishPayload,
RegistrationChallengeResponse,
};
use papercrate::models::{NewUserMembership, NewUserSession, TenantStatus, UserPasskey};
use papercrate::openapi::schemas::PasskeySummary;
use papercrate::schema::{tenants, user_memberships, user_sessions, users};
use papercrate::schema::{capability_sets, tenants, user_memberships, user_sessions, users};
use rand::rngs::OsRng;
use rand::RngCore;
use serde::Deserialize;
@@ -522,10 +524,16 @@ async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<()
))
.execute(conn)?;
let owner_capability_set_id =
ensure_capability_set(conn, secondary_id, owner_capabilities())
.map_err(|err| anyhow!("failed to ensure owner capability set: {:?}", err))?
.id;
let membership = NewUserMembership {
id: Uuid::new_v4(),
user_id,
tenant_id: secondary_id,
capability_set_id: Some(owner_capability_set_id),
};
diesel::insert_into(user_memberships::table)
@@ -592,10 +600,28 @@ async fn login_with_session(
let tenant: papercrate::models::Tenant =
tenants::table.find(membership.tenant_id).first(conn)?;
let capability_set_id = membership
.capability_set_id
.ok_or_else(|| anyhow!("membership missing capability set"))?;
let cap_version = capability_sets::table
.find(capability_set_id)
.select(capability_sets::cap_version)
.first::<i32>(conn)?;
let now = Utc::now();
let session_id = Uuid::new_v4();
let access_token = state
.jwt
.generate_token(user.id, tenant.id, &user.username)
.generate_token(AccessTokenContext {
user_id: user.id,
tenant_id: tenant.id,
username: user.username.clone(),
principal_kind: PrincipalKind::UserSession,
principal_id: session_id,
capability_set_id,
cap_version,
})
.map_err(|err| anyhow!(err))?;
let session_value = generate_session_token();
@@ -603,7 +629,7 @@ async fn login_with_session(
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
let new_session = NewUserSession {
id: Uuid::new_v4(),
id: session_id,
user_id: user.id,
token_hash: session_hash,
issued_at: now.naive_utc(),
+106
View File
@@ -0,0 +1,106 @@
mod common;
use anyhow::{anyhow, Result};
use axum::http::StatusCode;
use common::TestApp;
use diesel::prelude::*;
use papercrate::models::ApiCapability;
use serde_json::json;
async fn set_user_capabilities(
app: &TestApp,
user_id: uuid::Uuid,
caps: &[ApiCapability],
) -> Result<()> {
let capabilities = caps.to_vec();
app.with_conn(move |conn| {
use papercrate::schema::user_memberships::dsl as memberships_dsl;
let membership = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(user_id))
.first::<papercrate::models::UserMembership>(conn)?;
let capability_set = papercrate::auth::capability_sets::ensure_capability_set(
conn,
membership.tenant_id,
&capabilities,
)
.map_err(|err| anyhow!("failed to ensure capability set: {:?}", err))?;
diesel::update(memberships_dsl::user_memberships.find(membership.id))
.set(memberships_dsl::capability_set_id.eq(Some(capability_set.id)))
.execute(conn)?;
Ok(())
})
.await
}
#[tokio::test]
async fn documents_routes_enforce_capabilities() -> Result<()> {
let _lock = common::acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "limited-docs";
let user_id = app.insert_user("limited-docs", password, "admin").await?;
set_user_capabilities(&app, user_id, &[ApiCapability::DocumentsRead]).await?;
let token = app.login_token("limited-docs", password).await?;
let list = app.get("/api/documents", Some(&token)).await?;
assert_eq!(list.status(), StatusCode::OK);
let upload = app
.upload_document(
"/api/documents",
"limited.txt",
"text/plain",
b"limited",
None,
&token,
)
.await?;
assert_eq!(upload.status(), StatusCode::FORBIDDEN);
let upload_body = common::body_to_vec(upload.into_body()).await?;
assert!(String::from_utf8_lossy(&upload_body).contains("missing"));
let capability_sets = app.get("/api/capability-sets", Some(&token)).await?;
assert_eq!(capability_sets.status(), StatusCode::FORBIDDEN);
let caps_body = common::body_to_vec(capability_sets.into_body()).await?;
assert!(String::from_utf8_lossy(&caps_body).contains("missing"));
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn capability_set_routes_require_write_privilege() -> Result<()> {
let _lock = common::acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "caps-reader";
let user_id = app.insert_user("caps-reader", password, "admin").await?;
set_user_capabilities(&app, user_id, &[ApiCapability::CapabilitySetsRead]).await?;
let token = app.login_token("caps-reader", password).await?;
let list = app.get("/api/capability-sets", Some(&token)).await?;
assert_eq!(list.status(), StatusCode::OK);
let create = app
.post_json(
"/api/capability-sets",
&json!({
"slug": "should-fail",
"capabilities": ["documents:read"]
}),
Some(&token),
)
.await?;
assert_eq!(create.status(), StatusCode::FORBIDDEN);
let create_body = common::body_to_vec(create.into_body()).await?;
assert!(String::from_utf8_lossy(&create_body).contains("missing"));
app.cleanup().await?;
Ok(())
}
+104
View File
@@ -0,0 +1,104 @@
mod common;
use anyhow::Result;
use axum::http::StatusCode;
use common::{acquire_db_lock, body_to_vec, TestApp};
use serde::Deserialize;
use serde_json::json;
use uuid::Uuid;
#[derive(Deserialize)]
struct CapabilitySetResponse {
id: Uuid,
slug: String,
cap_version: i32,
#[allow(dead_code)]
is_system: bool,
capabilities: Vec<String>,
}
#[tokio::test]
async fn capability_set_crud_flow() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "caps-admin";
app.insert_user("caps", password, "admin").await?;
let token = app.login_token("caps", password).await?;
// Initial list should contain system sets.
let initial = app.get("/api/capability-sets", Some(&token)).await?;
assert_eq!(initial.status(), StatusCode::OK);
let initial_body = body_to_vec(initial.into_body()).await?;
let sets: Vec<CapabilitySetResponse> = serde_json::from_slice(&initial_body)?;
let owner_id = sets
.iter()
.find(|set| set.slug == "owner")
.map(|set| set.id)
.expect("owner set present");
assert!(sets.iter().any(|set| set.slug == "user"));
assert!(sets.iter().any(|set| set.slug == "webdav"));
// Create a new capability set.
let create = app
.post_json(
"/api/capability-sets",
&json!({
"slug": "api_readonly",
"capabilities": ["documents:read", "capability_sets:read"]
}),
Some(&token),
)
.await?;
assert_eq!(create.status(), StatusCode::CREATED);
let create_body = body_to_vec(create.into_body()).await?;
let created: CapabilitySetResponse = serde_json::from_slice(&create_body)?;
assert_eq!(created.slug, "api_readonly");
assert!(created.capabilities.contains(&"documents:read".to_string()));
assert!(created
.capabilities
.contains(&"capability_sets:read".to_string()));
// Update capabilities.
let update = app
.patch_json(
&format!("/api/capability-sets/{}", created.id),
&json!({
"capabilities": ["documents:read", "documents:edit"],
}),
Some(&token),
)
.await?;
assert_eq!(update.status(), StatusCode::OK);
let update_body = body_to_vec(update.into_body()).await?;
let updated: CapabilitySetResponse = serde_json::from_slice(&update_body)?;
assert_eq!(updated.cap_version, created.cap_version + 1);
assert!(updated.capabilities.contains(&"documents:edit".to_string()));
assert!(!updated
.capabilities
.contains(&"capability_sets:read".to_string()));
// Attempt to delete system set should conflict.
let delete_owner = app
.delete(&format!("/api/capability-sets/{}", owner_id), Some(&token))
.await?;
assert_eq!(delete_owner.status(), StatusCode::CONFLICT);
// Delete custom set succeeds.
let delete = app
.delete(
&format!("/api/capability-sets/{}", created.id),
Some(&token),
)
.await?;
assert_eq!(delete.status(), StatusCode::NO_CONTENT);
let final_list = app.get("/api/capability-sets", Some(&token)).await?;
assert_eq!(final_list.status(), StatusCode::OK);
let final_body = body_to_vec(final_list.into_body()).await?;
let final_sets: Vec<CapabilitySetResponse> = serde_json::from_slice(&final_body)?;
assert!(!final_sets.iter().any(|set| set.slug == "api_readonly"));
app.cleanup().await?;
Ok(())
}
+135 -72
View File
@@ -16,7 +16,10 @@ use diesel::PgConnection;
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
use http_body_util::BodyExt;
use once_cell::sync::Lazy;
use papercrate::auth::jwt::JwtService;
use papercrate::auth::capability_sets::{
ensure_capability_set, owner_capabilities, user_capabilities, webdav_capabilities,
};
use papercrate::auth::jwt::{AccessTokenContext, JwtService, PrincipalKind};
use papercrate::config::AppConfig;
use papercrate::db::{self, PgPool};
use papercrate::models::{
@@ -81,7 +84,12 @@ impl ObjectStorage for FakeStorage {
Ok(())
}
async fn presign_get_object(&self, key: &str, expires_in: Duration) -> Result<String> {
async fn presign_get_object(
&self,
key: &str,
expires_in: Duration,
_response_content_disposition: Option<&str>,
) -> Result<String> {
let guard = self.objects.lock().await;
ensure!(guard.contains_key(key), "object {key} missing");
Ok(format!(
@@ -217,34 +225,49 @@ impl TestApp {
Ok(format!("{}{}", root, key))
}
pub async fn insert_user(&self, username: &str, _password: &str, _role: &str) -> Result<Uuid> {
pub async fn insert_user(&self, username: &str, _password: &str, role: &str) -> Result<Uuid> {
let username = username.to_string();
let role = role.to_string();
let tenant_id = self.ensure_default_tenant().await?;
self.with_conn(move |conn| {
let user = NewUser {
id: Uuid::new_v4(),
username,
};
diesel::insert_into(papercrate::schema::users::table)
.values(&user)
.execute(conn)
.context("failed to insert user")?;
let user_id = self
.with_conn(move |conn| {
let user = NewUser {
id: Uuid::new_v4(),
username,
};
diesel::insert_into(papercrate::schema::users::table)
.values(&user)
.execute(conn)
.context("failed to insert user")?;
let membership = NewUserMembership {
id: Uuid::new_v4(),
user_id: user.id,
tenant_id,
};
let capabilities = match role.as_str() {
"admin" => owner_capabilities(),
"webdav" => webdav_capabilities(),
_ => user_capabilities(),
};
diesel::insert_into(papercrate::schema::user_memberships::table)
.values(&membership)
.execute(conn)
.context("failed to insert user membership")?;
Ok(user.id)
})
.await
let capability_set = ensure_capability_set(conn, tenant_id, capabilities)
.map_err(|err| anyhow!("failed to ensure capability set: {:?}", err))?;
let membership = NewUserMembership {
id: Uuid::new_v4(),
user_id: user.id,
tenant_id,
capability_set_id: Some(capability_set.id),
};
diesel::insert_into(papercrate::schema::user_memberships::table)
.values(&membership)
.execute(conn)
.context("failed to insert user membership")?;
Ok(user.id)
})
.await?;
Ok(user_id)
}
#[cfg(test)]
pub async fn insert_passkey(&self, user_id: Uuid, nickname: Option<&str>) -> Result<Uuid> {
let passkey_id = Uuid::new_v4();
let nickname = nickname.map(|value| value.to_string());
@@ -276,56 +299,71 @@ impl TestApp {
async fn ensure_default_tenant(&self) -> Result<Uuid> {
let name_value = TEST_TENANT_NAME.to_string();
let quickwit_enabled = self.state.config.quickwit_endpoint.is_some();
self.with_conn(move |conn| {
use papercrate::schema::tenants::dsl as tenants_dsl;
let tenant_id = self
.with_conn(move |conn| {
use papercrate::schema::tenants::dsl as tenants_dsl;
let existing = tenants_dsl::tenants
.filter(tenants_dsl::name.eq(&name_value))
.first::<Tenant>(conn)
.optional()
.context("failed to load default tenant")?;
let existing = tenants_dsl::tenants
.filter(tenants_dsl::name.eq(&name_value))
.first::<Tenant>(conn)
.optional()
.context("failed to load default tenant")?;
let tenant_id = if let Some(current) = existing {
let desired_root = current
.storage_root
.clone()
.filter(|root| root.ends_with('/'))
.unwrap_or_else(|| format!("test-tenants/{}/", current.id));
let tenant_id = if let Some(current) = existing {
let desired_root = current
.storage_root
.clone()
.filter(|root| root.ends_with('/'))
.unwrap_or_else(|| format!("test-tenants/{}/", current.id));
if current.storage_root.as_deref() != Some(desired_root.as_str()) {
diesel::update(tenants_dsl::tenants.filter(tenants_dsl::id.eq(current.id)))
.set(tenants_dsl::storage_root.eq(Some(desired_root)))
.execute(conn)
.context("failed to update default tenant storage root")?;
}
if current.storage_root.as_deref() != Some(desired_root.as_str()) {
diesel::update(tenants_dsl::tenants.filter(tenants_dsl::id.eq(current.id)))
.set(tenants_dsl::storage_root.eq(Some(desired_root)))
.execute(conn)
.context("failed to update default tenant storage root")?;
}
current.id
} else {
let new_id = Uuid::new_v4();
let root = format!("test-tenants/{}/", new_id);
let quickwit_value = if quickwit_enabled {
Some(format!("documents-{}", new_id))
current.id
} else {
None
let new_id = Uuid::new_v4();
let root = format!("test-tenants/{}/", new_id);
let quickwit_value = if quickwit_enabled {
Some(format!("documents-{}", new_id))
} else {
None
};
diesel::insert_into(tenants_dsl::tenants)
.values((
tenants_dsl::id.eq(new_id),
tenants_dsl::name.eq(&name_value),
tenants_dsl::storage_root.eq(Some(root)),
tenants_dsl::quickwit_index.eq(quickwit_value),
tenants_dsl::status.eq(TenantStatus::Active),
))
.execute(conn)
.context("failed to insert default tenant")?;
new_id
};
diesel::insert_into(tenants_dsl::tenants)
.values((
tenants_dsl::id.eq(new_id),
tenants_dsl::name.eq(&name_value),
tenants_dsl::storage_root.eq(Some(root)),
tenants_dsl::quickwit_index.eq(quickwit_value),
tenants_dsl::status.eq(TenantStatus::Active),
))
.execute(conn)
.context("failed to insert default tenant")?;
Ok(tenant_id)
})
.await?;
new_id
};
let mut conn = self
.state
.db_for_tenant(tenant_id)
.map_err(|err| anyhow!("failed to scope tenant connection: {err:?}"))?;
Ok(tenant_id)
})
.await
ensure_capability_set(&mut conn, tenant_id, owner_capabilities())
.map_err(|err| anyhow!("ensure owner capability set: {err:?}"))?;
ensure_capability_set(&mut conn, tenant_id, user_capabilities())
.map_err(|err| anyhow!("ensure user capability set: {err:?}"))?;
ensure_capability_set(&mut conn, tenant_id, webdav_capabilities())
.map_err(|err| anyhow!("ensure webdav capability set: {err:?}"))?;
Ok(tenant_id)
}
pub async fn login_token(&self, username: &str, _password: &str) -> Result<String> {
@@ -337,6 +375,7 @@ impl TestApp {
let username = username.to_string();
let state = self.state.clone();
self.with_conn(move |conn| {
use papercrate::schema::capability_sets::dsl as capability_sets_dsl;
use papercrate::schema::tenants::dsl as tenants_dsl;
use papercrate::schema::user_memberships::dsl as memberships_dsl;
use papercrate::schema::users::dsl as users_dsl;
@@ -353,10 +392,28 @@ impl TestApp {
.find(membership.tenant_id)
.first(conn)?;
let capability_set_id = membership
.capability_set_id
.ok_or_else(|| anyhow!("membership missing capability set"))?;
let cap_version = capability_sets_dsl::capability_sets
.find(capability_set_id)
.select(capability_sets_dsl::cap_version)
.first::<i32>(conn)?;
let now = Utc::now();
let session_id = Uuid::new_v4();
let access_token = state
.jwt
.generate_token(user.id, tenant.id, &user.username)
.generate_token(AccessTokenContext {
user_id: user.id,
tenant_id: tenant.id,
username: user.username.clone(),
principal_kind: PrincipalKind::UserSession,
principal_id: session_id,
capability_set_id,
cap_version,
})
.map_err(|err| anyhow!(err))?;
let session_value = generate_session_token();
@@ -365,7 +422,7 @@ impl TestApp {
now + ChronoDuration::days(state.config.refresh_token_expiry_days);
let new_session = NewUserSession {
id: Uuid::new_v4(),
id: session_id,
user_id: user.id,
token_hash: session_hash,
issued_at: now.naive_utc(),
@@ -540,7 +597,7 @@ impl TestApp {
tag_ids_json: None,
correspondents_json: None,
issued_at: None,
skip_existing: false,
skip_existing: None,
};
self.upload_document_with_extras(
path,
@@ -620,9 +677,15 @@ impl TestApp {
body.extend(b"\r\n");
}
if extras.skip_existing {
if let Some(skip_flag) = extras.skip_existing {
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(b"Content-Disposition: form-data; name=\"skip_existing\"\r\n\r\ntrue\r\n");
body.extend(b"Content-Disposition: form-data; name=\"skip_existing\"\r\n\r\n");
body.extend(if skip_flag {
b"true".as_ref()
} else {
b"false".as_ref()
});
body.extend(b"\r\n");
}
body.extend(format!("--{boundary}--\r\n").as_bytes());
@@ -668,7 +731,7 @@ pub struct UploadExtras<'a> {
pub tag_ids_json: Option<&'a str>,
pub correspondents_json: Option<&'a str>,
pub issued_at: Option<&'a str>,
pub skip_existing: bool,
pub skip_existing: Option<bool>,
}
impl<'a> UploadExtras<'a> {
@@ -679,7 +742,7 @@ impl<'a> UploadExtras<'a> {
tag_ids_json: None,
correspondents_json: None,
issued_at: None,
skip_existing: false,
skip_existing: None,
}
}
}
+209 -47
View File
@@ -1,12 +1,16 @@
mod common;
use anyhow::Result;
use anyhow::{anyhow, Result};
use axum::http::StatusCode;
use common::{acquire_db_lock, body_to_vec, TestApp, UploadExtras};
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use uuid::Uuid;
use papercrate::jobs::{mark_job_succeeded, JOB_PURGE_DOCUMENT};
use papercrate::models::Job;
use papercrate::workers::{purge::PurgeDocumentJob, JobExecution, JobHandler};
use std::sync::Arc;
#[derive(Deserialize)]
struct DocumentDetail {
document: DocumentInfo,
@@ -17,6 +21,8 @@ struct ApiErrorResponse {
error: String,
#[serde(default)]
code: Option<String>,
#[serde(default)]
details: Option<Value>,
}
#[derive(Deserialize)]
@@ -291,15 +297,16 @@ async fn duplicate_and_restore_document() -> Result<()> {
&token,
)
.await?;
{
let status = first.status();
assert!(
status == StatusCode::OK
|| status == StatusCode::CREATED
|| status == StatusCode::NO_CONTENT
);
}
let first_status = first.status();
let first_body = body_to_vec(first.into_body()).await?;
assert!(
first_status == StatusCode::OK
|| first_status == StatusCode::CREATED
|| first_status == StatusCode::NO_CONTENT,
"unexpected first upload status {} with body {}",
first_status,
String::from_utf8_lossy(&first_body)
);
let first_detail: DocumentDetail = serde_json::from_slice(&first_body)?;
let second = app
@@ -312,59 +319,69 @@ async fn duplicate_and_restore_document() -> Result<()> {
&token,
)
.await?;
{
let status = second.status();
assert!(
status == StatusCode::OK
|| status == StatusCode::CREATED
|| status == StatusCode::NO_CONTENT
);
}
let second_status = second.status();
let second_body = body_to_vec(second.into_body()).await?;
let second_detail: DocumentDetail = serde_json::from_slice(&second_body)?;
assert_eq!(first_detail.document.id, second_detail.document.id);
assert_eq!(second_detail.document.deleted_at, None);
assert!(second_detail
.document
.current_version
assert_eq!(second_status, StatusCode::CONFLICT);
let second_error: ApiErrorResponse = serde_json::from_slice(&second_body)?;
assert_eq!(second_error.code.as_deref(), Some("duplicate_document"));
let conflict_id = second_error
.details
.as_ref()
.expect("second current version")
.assets
.is_empty());
.and_then(|details| details.get("conflict_document_id"))
.and_then(|value| value.as_str())
.and_then(|value| Uuid::parse_str(value).ok())
.expect("conflict_document_id present");
assert_eq!(conflict_id, first_detail.document.id);
assert_eq!(app.storage().object_count().await, 1);
let delete = app
.delete(
&format!("/api/documents/{}", first_detail.document.id),
.post_json(
&format!("/api/documents/{}/trash", first_detail.document.id),
&json!({}),
Some(&token),
)
.await?;
assert_eq!(delete.status(), StatusCode::NO_CONTENT);
let third = app
.upload_document(
.upload_document_with_extras(
"/api/documents",
"dup.bin",
"application/octet-stream",
&payload,
None,
UploadExtras {
title: None,
metadata_json: None,
tag_ids_json: None,
correspondents_json: None,
issued_at: None,
skip_existing: Some(false),
},
&token,
)
.await?;
{
let status = third.status();
assert!(
status == StatusCode::OK
|| status == StatusCode::CREATED
|| status == StatusCode::NO_CONTENT
);
}
let third_status = third.status();
let third_body = body_to_vec(third.into_body()).await?;
assert!(
third_status == StatusCode::OK
|| third_status == StatusCode::CREATED
|| third_status == StatusCode::NO_CONTENT,
"unexpected third upload status {} with body {}",
third_status,
String::from_utf8_lossy(&third_body)
);
let third_detail: DocumentDetail = serde_json::from_slice(&third_body)?;
assert_eq!(third_detail.document.id, first_detail.document.id);
assert_eq!(third_detail.document.deleted_at, None);
assert!(third_detail
.document
.current_version
.as_ref()
.expect("third current version")
.assets
.is_empty());
assert_eq!(app.storage().object_count().await, 1);
app.cleanup().await?;
@@ -406,7 +423,7 @@ async fn upload_skips_existing_when_requested() -> Result<()> {
tag_ids_json: Some(primary_tag_ids.as_str()),
correspondents_json: None,
issued_at: None,
skip_existing: false,
skip_existing: None,
};
let first_upload = app
@@ -456,7 +473,7 @@ async fn upload_skips_existing_when_requested() -> Result<()> {
tag_ids_json: Some(alt_tag_ids.as_str()),
correspondents_json: None,
issued_at: None,
skip_existing: true,
skip_existing: Some(true),
};
let skip_resp = app
@@ -470,7 +487,18 @@ async fn upload_skips_existing_when_requested() -> Result<()> {
&token,
)
.await?;
assert_eq!(skip_resp.status(), StatusCode::NO_CONTENT);
assert_eq!(skip_resp.status(), StatusCode::CONFLICT);
let skip_body = body_to_vec(skip_resp.into_body()).await?;
let skip_error: ApiErrorResponse = serde_json::from_slice(&skip_body)?;
assert_eq!(skip_error.code.as_deref(), Some("duplicate_document"));
let conflict_id = skip_error
.details
.as_ref()
.and_then(|details| details.get("conflict_document_id"))
.and_then(|value| value.as_str())
.and_then(|value| Uuid::parse_str(value).ok())
.expect("conflict_document_id present");
assert_eq!(conflict_id, first_detail.document.id);
let fetch = app
.get(
@@ -1407,8 +1435,9 @@ async fn list_documents_by_status_filter() -> Result<()> {
let detail: DocumentDetail = serde_json::from_slice(&body)?;
let delete_resp = app
.delete(
&format!("/api/documents/{}", detail.document.id),
.post_json(
&format!("/api/documents/{}/trash", detail.document.id),
&json!({}),
Some(&token),
)
.await?;
@@ -1437,6 +1466,137 @@ async fn list_documents_by_status_filter() -> Result<()> {
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn purge_document_removes_data() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "purge";
app.insert_user("purger", password, "admin").await?;
let token = app.login_token("purger", password).await?;
let upload = app
.upload_document(
"/api/documents",
"purge.bin",
"application/octet-stream",
b"permanent",
None,
&token,
)
.await?;
let body = body_to_vec(upload.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&body)?;
let document_id = detail.document.id;
assert_eq!(app.storage().object_count().await, 1);
let trash_resp = app
.post_json(
&format!("/api/documents/{}/trash", document_id),
&json!({}),
Some(&token),
)
.await?;
assert_eq!(trash_resp.status(), StatusCode::NO_CONTENT);
let delete_resp = app
.delete(&format!("/api/documents/{}", document_id), Some(&token))
.await?;
assert_eq!(delete_resp.status(), StatusCode::ACCEPTED);
let duplicate_delete = app
.delete(&format!("/api/documents/{}", document_id), Some(&token))
.await?;
assert_eq!(duplicate_delete.status(), StatusCode::ACCEPTED);
let purge_job_count: i64 = app
.with_conn(|conn| {
use diesel::dsl::count_star;
use diesel::prelude::*;
use papercrate::schema::jobs::dsl::*;
let count: i64 = jobs
.filter(job_type.eq(JOB_PURGE_DOCUMENT))
.select(count_star())
.get_result(conn)?;
Ok(count)
})
.await?;
assert_eq!(purge_job_count, 1);
let job: Job = app
.with_conn(|conn| {
use diesel::prelude::*;
use papercrate::schema::jobs::dsl::*;
let job = jobs
.filter(job_type.eq(JOB_PURGE_DOCUMENT))
.order(created_at.desc())
.first(conn)?;
Ok(job)
})
.await?;
let handler = PurgeDocumentJob::new();
let state = Arc::new(app.state.clone());
let storage = app
.state
.storage_for_tenant(job.tenant_id)
.map_err(|err| anyhow!("tenant storage unavailable: {err:?}"))?;
let execution = handler.handle(state, job.clone(), storage).await;
assert!(matches!(execution, JobExecution::Success));
app.with_conn(move |conn| {
mark_job_succeeded(conn, job.id)?;
Ok(())
})
.await?;
let fetch = app
.get(&format!("/api/documents/{}", document_id), Some(&token))
.await?;
assert_eq!(fetch.status(), StatusCode::NOT_FOUND);
assert_eq!(app.storage().object_count().await, 0);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn delete_document_requires_trash() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "conflict";
app.insert_user("conflict-user", password, "admin").await?;
let token = app.login_token("conflict-user", password).await?;
let upload = app
.upload_document(
"/api/documents",
"conflict.bin",
"application/octet-stream",
b"restore",
None,
&token,
)
.await?;
let body = body_to_vec(upload.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&body)?;
let delete_resp = app
.delete(
&format!("/api/documents/{}", detail.document.id),
Some(&token),
)
.await?;
assert_eq!(delete_resp.status(), StatusCode::CONFLICT);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn restore_document_to_original_and_custom_folder() -> Result<()> {
@@ -1461,8 +1621,9 @@ async fn restore_document_to_original_and_custom_folder() -> Result<()> {
let detail: DocumentDetail = serde_json::from_slice(&body)?;
let delete_resp = app
.delete(
&format!("/api/documents/{}", detail.document.id),
.post_json(
&format!("/api/documents/{}/trash", detail.document.id),
&json!({}),
Some(&token),
)
.await?;
@@ -1503,8 +1664,9 @@ async fn restore_document_to_original_and_custom_folder() -> Result<()> {
let folder: FolderResponse = serde_json::from_slice(&folder_body)?;
let delete_again = app
.delete(
&format!("/api/documents/{}", detail.document.id),
.post_json(
&format!("/api/documents/{}/trash", detail.document.id),
&json!({}),
Some(&token),
)
.await?;
+8 -1
View File
@@ -1,9 +1,10 @@
mod common;
use anyhow::Result;
use anyhow::{anyhow, Result};
use axum::http::StatusCode;
use common::{acquire_db_lock, body_to_vec, TestApp};
use diesel::prelude::*;
use papercrate::auth::capability_sets::{ensure_capability_set, owner_capabilities};
use papercrate::models::{NewUser, NewUserMembership, Tag, TenantStatus};
use papercrate::schema::{
tags::dsl as tags_dsl, tenants::dsl as tenants_dsl, user_memberships::dsl as memberships_dsl,
@@ -223,10 +224,16 @@ async fn tags_are_isolated_between_tenants() -> Result<()> {
.values(&new_user)
.execute(conn)?;
let owner_capability_set_id =
ensure_capability_set(conn, tenant_b_id, owner_capabilities())
.map_err(|err| anyhow!("failed to ensure owner capability set: {:?}", err))?
.id;
let membership = NewUserMembership {
id: Uuid::new_v4(),
user_id: user_b_id,
tenant_id: tenant_b_id,
capability_set_id: Some(owner_capability_set_id),
};
diesel::insert_into(memberships_dsl::user_memberships)
.values(&membership)