Initial commit
ci / docker (backend, backend/Dockerfile, backend) (push) Failing after 13s
ci / docker (frontend, frontend/Dockerfile, frontend) (push) Failing after 12s

This commit is contained in:
2025-12-17 13:37:31 +01:00
commit 3c9a9fc060
399 changed files with 80777 additions and 0 deletions
+402
View File
@@ -0,0 +1,402 @@
use anyhow::{Context, Result};
use axum::body::Body;
use axum::http::{header, Method, Request, StatusCode};
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine;
use diesel::prelude::*;
use diesel::OptionalExtension;
use papercrate::auth::capability_sets;
use papercrate::models::{ApiCapability, ApiToken};
use papercrate::routes::webdav;
use papercrate::schema::api_tokens;
use papercrate::schema::capability_sets::dsl as capability_sets_dsl;
use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole};
use serde::Deserialize;
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 READ_ONLY_CAPS: &[&str] = &["documents:read"];
const LIMITED_WEBDAV_CAPS: &[&str] = &["documents:read", "webdav:read"];
#[derive(Debug, Deserialize)]
struct TokenInfo {
id: Uuid,
label: Option<String>,
last_used_at: Option<String>,
revoked_at: Option<String>,
capability_set_id: Uuid,
}
#[derive(Debug, Deserialize)]
struct CreateTokenResponse {
token: String,
#[serde(rename = "token_info")]
info: TokenInfo,
}
#[derive(Debug, Deserialize)]
struct LoginResponseView {
access_token: String,
token_type: String,
expires_in: i64,
tenant: TenantView,
}
#[derive(Debug, Deserialize)]
struct TenantView {
id: Uuid,
name: String,
}
#[tokio::test]
async fn api_token_crud_flow() -> Result<()> {
let _guard = acquire_db_lock().await;
let app = TestApp::new().await?;
let username = "alice";
let password = "correct horse battery";
app.insert_user(username, TestUserRole::Owner).await?;
let access_token = app.login_token(username, password).await?;
let legacy_set_id =
ensure_capability_set_slug(&app, &access_token, "legacy_webdav", LEGACY_WEBDAV_CAPS)
.await?;
let created = create_token(&app, &access_token, Some("dav"), legacy_set_id, None).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.capability_set_id, legacy_set_id);
let regenerated = regenerate_token(&app, &access_token, token_id).await?;
assert_eq!(regenerated.info.id, token_id);
assert_ne!(regenerated.token, created.token);
assert!(regenerated.info.last_used_at.is_none());
let listed = list_tokens(&app, &access_token).await?;
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].id, token_id);
assert_eq!(listed[0].capability_set_id, legacy_set_id);
let tenant_id_for_token = app
.with_conn(move |conn| {
let tenant_id = api_tokens::table
.find(token_id)
.select(api_tokens::tenant_id)
.first::<Uuid>(conn)?;
Ok::<_, anyhow::Error>(tenant_id)
})
.await?;
let readonly_set_id =
ensure_capability_set_slug(&app, &access_token, "readonly", READ_ONLY_CAPS).await?;
let readonly_token =
create_token(&app, &access_token, Some("readonly"), readonly_set_id, None).await?;
let readonly_exchange = exchange_token(&app, &readonly_token.token).await?;
assert_eq!(readonly_exchange.tenant.id, tenant_id_for_token);
delete_token(&app, &access_token, readonly_token.info.id).await?;
let exchange = exchange_token(&app, &regenerated.token).await?;
assert_eq!(exchange.token_type, "Bearer");
assert!(!exchange.access_token.is_empty());
assert!(exchange.expires_in > 0);
assert_eq!(exchange.tenant.id, tenant_id_for_token);
assert!(!exchange.tenant.name.is_empty());
delete_token(&app, &access_token, token_id).await?;
let listed_after = list_tokens(&app, &access_token).await?;
let revoked_entry = listed_after
.iter()
.find(|entry| entry.id == token_id)
.expect("revoked token still listed");
assert!(revoked_entry.revoked_at.is_some());
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn webdav_basic_auth_uses_api_tokens() -> Result<()> {
let _guard = acquire_db_lock().await;
let app = TestApp::new().await?;
let username = "bruce";
let password = "wayne";
app.insert_user(username, TestUserRole::Owner).await?;
let access_token = app.login_token(username, password).await?;
let legacy_set_id =
ensure_capability_set_slug(&app, &access_token, "legacy_webdav", LEGACY_WEBDAV_CAPS)
.await?;
let created = create_token(&app, &access_token, Some("webdav"), legacy_set_id, None).await?;
let token_id = created.info.id;
let router = webdav::create_router().with_state(app.state.clone());
let original_secret = created.token.clone();
let auth_header = format!(
"Basic {}",
BASE64.encode(format!("{}:{}", username, original_secret))
);
let propfind = Method::from_bytes(b"PROPFIND")?;
let success_request = Request::builder()
.method(propfind.clone())
.uri("/")
.header(header::AUTHORIZATION, auth_header.clone())
.header("depth", "0")
.body(Body::empty())?;
let response = router.clone().oneshot(success_request).await?;
assert_eq!(response.status(), StatusCode::MULTI_STATUS);
let used = app
.with_conn(move |conn| {
let record = api_tokens::table.find(token_id).first::<ApiToken>(conn)?;
Ok::<_, anyhow::Error>(record.last_used_at)
})
.await?;
assert!(used.is_some());
let regenerated = regenerate_token(&app, &access_token, token_id).await?;
assert_ne!(regenerated.token, original_secret);
let unused_after_regen = app
.with_conn(move |conn| {
let record = api_tokens::table.find(token_id).first::<ApiToken>(conn)?;
Ok::<_, anyhow::Error>(record.last_used_at)
})
.await?;
assert!(unused_after_regen.is_none());
let old_secret_request = Request::builder()
.method(propfind.clone())
.uri("/")
.header(
header::AUTHORIZATION,
format!(
"Basic {}",
BASE64.encode(format!("{}:{}", username, original_secret))
),
)
.header("depth", "0")
.body(Body::empty())?;
let old_secret_response = router.clone().oneshot(old_secret_request).await?;
assert_eq!(old_secret_response.status(), StatusCode::UNAUTHORIZED);
let new_secret_header = format!(
"Basic {}",
BASE64.encode(format!("{}:{}", username, regenerated.token))
);
let success_request = Request::builder()
.method(propfind.clone())
.uri("/")
.header(header::AUTHORIZATION, new_secret_header.clone())
.header("depth", "0")
.body(Body::empty())?;
let response = router.clone().oneshot(success_request).await?;
assert_eq!(response.status(), StatusCode::MULTI_STATUS);
// Token without webdav_read cannot authenticate.
let read_only_set_id =
ensure_capability_set_slug(&app, &access_token, "documents_read", READ_ONLY_CAPS).await?;
let limited_token =
create_token(&app, &access_token, Some("limited"), read_only_set_id, None).await?;
assert_eq!(limited_token.info.capability_set_id, read_only_set_id);
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 limited_set_id = ensure_capability_set_slug(
&app,
&access_token,
"documents_read_webdav",
LIMITED_WEBDAV_CAPS,
)
.await?;
let upgraded_token = create_token(
&app,
&access_token,
Some("limited-webdav"),
limited_set_id,
None,
)
.await?;
assert_eq!(upgraded_token.info.capability_set_id, limited_set_id);
let upgraded_request = Request::builder()
.method(propfind.clone())
.uri("/")
.header(
header::AUTHORIZATION,
format!(
"Basic {}",
BASE64.encode(format!("{}:{}", username, upgraded_token.token))
),
)
.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()
.method(propfind)
.uri("/")
.header(header::AUTHORIZATION, new_secret_header)
.header("depth", "0")
.body(Body::empty())?;
let failure_response = router.oneshot(failure_request).await?;
assert_eq!(failure_response.status(), StatusCode::UNAUTHORIZED);
app.cleanup().await?;
Ok(())
}
async fn create_token(
app: &TestApp,
access_token: &str,
label: Option<&str>,
capability_set_id: Uuid,
expires_at: Option<&str>,
) -> Result<CreateTokenResponse> {
let mut payload = json!({
"capability_set_id": capability_set_id,
});
if let Some(label) = label {
payload["label"] = json!(label);
}
if let Some(expires) = expires_at {
payload["expires_at"] = json!(expires);
}
let response = app
.post_json("/api/profile/api-tokens", &payload, Some(access_token))
.await?;
assert_eq!(response.status(), StatusCode::CREATED);
let body = body_to_vec(response.into_body()).await?;
Ok(serde_json::from_slice(&body)?)
}
async fn regenerate_token(
app: &TestApp,
access_token: &str,
token_id: Uuid,
) -> Result<CreateTokenResponse> {
let response = app
.post_json(
&format!("/api/profile/api-tokens/{token_id}/regenerate"),
&json!({}),
Some(access_token),
)
.await?;
assert_eq!(response.status(), StatusCode::OK);
let body = body_to_vec(response.into_body()).await?;
Ok(serde_json::from_slice(&body)?)
}
async fn list_tokens(app: &TestApp, access_token: &str) -> Result<Vec<TokenInfo>> {
let response = app
.get("/api/profile/api-tokens", Some(access_token))
.await?;
assert_eq!(response.status(), StatusCode::OK);
let body = body_to_vec(response.into_body()).await?;
Ok(serde_json::from_slice(&body)?)
}
async fn delete_token(app: &TestApp, access_token: &str, token_id: Uuid) -> Result<()> {
let response = app
.delete(
&format!("/api/profile/api-tokens/{token_id}"),
Some(access_token),
)
.await?;
assert_eq!(response.status(), StatusCode::NO_CONTENT);
Ok(())
}
async fn ensure_capability_set_slug(
app: &TestApp,
access_token: &str,
slug: &str,
capabilities: &[&str],
) -> Result<Uuid> {
let claims = app
.state
.jwt
.verify_token(access_token)
.context("failed to decode access token claims")?;
let tenant_id = claims.tenant_id;
let slug = slug.to_string();
let desired_capabilities = capabilities
.iter()
.map(|value| {
value
.parse::<ApiCapability>()
.map_err(|err| anyhow::anyhow!("invalid capability '{value}': {err}"))
})
.collect::<Result<Vec<_>>>()?;
let caps_for_insert = desired_capabilities.clone();
app.with_conn(move |conn| {
if let Some(existing) = capability_sets_dsl::capability_sets
.filter(capability_sets_dsl::tenant_id.eq(tenant_id))
.filter(capability_sets_dsl::slug.eq(&slug))
.select(capability_sets_dsl::id)
.first::<Uuid>(conn)
.optional()?
{
return Ok(existing);
}
let created =
capability_sets::create_capability_set(conn, tenant_id, &slug, caps_for_insert)
.map_err(|err| {
anyhow::anyhow!("failed to create capability set '{slug}': {err:?}")
})?;
Ok(created.id)
})
.await
}
async fn exchange_token(app: &TestApp, api_token: &str) -> Result<LoginResponseView> {
let response = app
.post_json(
"/api/auth/exchange-api-token",
&json!({ "api_token": api_token }),
None,
)
.await?;
assert_eq!(response.status(), StatusCode::OK);
let body = body_to_vec(response.into_body()).await?;
Ok(serde_json::from_slice(&body)?)
}
+745
View File
@@ -0,0 +1,745 @@
use anyhow::{anyhow, Context, Result};
use axum::http::{header::SET_COOKIE, StatusCode};
use chrono::{Duration as ChronoDuration, Utc};
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::{
MagicToken, MagicTokenKind, NewUserMembership, NewUserSession, TenantStatus, UserPasskey,
};
use papercrate::openapi::schemas::PasskeySummary;
use papercrate::schema::{
capability_sets, magic_tokens::dsl as magic_dsl, tenants, tenants::dsl as tenant_dsl,
user_memberships, user_sessions, users,
};
use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole};
use rand::{rngs::OsRng, TryRngCore};
use serde::Deserialize;
use serde_json::json;
use sha2::{Digest, Sha256};
use uuid::Uuid;
use webauthn_rs_core::proto::{
AuthenticatorAssertionResponseRaw, AuthenticatorAttestationResponseRaw, PublicKeyCredential,
RegisterPublicKeyCredential,
};
#[derive(Deserialize)]
struct AuthenticatedUser {
username: String,
}
#[derive(Deserialize)]
struct ApiErrorResponse {
error: String,
#[serde(default)]
_code: Option<String>,
}
#[derive(Deserialize)]
struct LoginTenant {
id: Uuid,
name: String,
}
#[derive(Deserialize)]
struct LoginResponse {
access_token: String,
tenant: LoginTenant,
}
#[derive(Deserialize)]
struct SignupStartResponse {
signup_token: String,
challenge: RegistrationChallengeResponse,
}
#[derive(Deserialize)]
struct TenantSummary {
id: Uuid,
name: String,
}
#[tokio::test]
async fn login_and_me_roundtrip() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "s3cret";
app.insert_user("alice", TestUserRole::Owner).await?;
let (login, _) = login_with_session(&app, "alice", password).await?;
let response = app.get("/api/auth/me", Some(&login.access_token)).await?;
assert_eq!(response.status(), StatusCode::OK);
let body = body_to_vec(response.into_body()).await?;
let user: AuthenticatedUser = serde_json::from_slice(&body)?;
assert_eq!(user.username, "alice");
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn login_rejects_unknown_user() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let payload = json!({ "username": "ghost", "password": "nope" });
let response = app.post_json("/api/auth/login", &payload, None).await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = body_to_vec(response.into_body()).await?;
let err: ApiErrorResponse = serde_json::from_slice(&body)?;
assert_eq!(err.error, "password authentication is no longer supported");
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn signup_start_and_finish_require_valid_passkey() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let payload = json!({ "username": "signup-user" });
let response = app
.post_json("/api/auth/signup/start", &payload, None)
.await?;
assert_eq!(response.status(), StatusCode::OK);
let body = body_to_vec(response.into_body()).await?;
let start: SignupStartResponse = serde_json::from_slice(&body)?;
assert!(!start.signup_token.is_empty());
assert_ne!(start.challenge.challenge_id, Uuid::nil());
app.with_conn(|conn| {
let exists: bool = diesel::select(diesel::dsl::exists(
users::table.filter(users::username.eq("signup-user")),
))
.get_result(conn)?;
assert!(!exists);
Ok(())
})
.await?;
let finish_payload = json!({
"signup_token": start.signup_token,
"credential": fake_register_credential(),
});
let finish_response = app
.post_json("/api/auth/signup/finish", &finish_payload, None)
.await?;
assert_eq!(finish_response.status(), StatusCode::BAD_REQUEST);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn passkey_register_start_creates_challenge() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "secret";
app.insert_user("passkey-user", TestUserRole::Owner).await?;
let (login, _) = login_with_session(&app, "passkey-user", password).await?;
let response = app
.post_json(
"/api/auth/passkeys/register/start",
&json!({}),
Some(&login.access_token),
)
.await?;
assert_eq!(response.status(), StatusCode::OK);
let body = body_to_vec(response.into_body()).await?;
let challenge: RegistrationChallengeResponse = serde_json::from_slice(&body)?;
assert_ne!(challenge.challenge_id, Uuid::nil());
let challenge_id = challenge.challenge_id;
app.with_conn(move |conn| {
use diesel::dsl::{exists, select};
use papercrate::schema::webauthn_challenges::dsl;
let exists: bool = select(exists(
dsl::webauthn_challenges.filter(dsl::id.eq(challenge_id)),
))
.get_result(conn)?;
assert!(exists, "challenge not persisted");
Ok(())
})
.await?;
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn passkey_register_finish_rejects_unknown_challenge() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "secret";
app.insert_user("passkey-register", TestUserRole::Owner)
.await?;
let (login, _) = login_with_session(&app, "passkey-register", password).await?;
let payload = PasskeyRegistrationFinishPayload {
challenge_id: Uuid::new_v4(),
credential: fake_register_credential(),
nickname: None,
};
let response = app
.post_json(
"/api/auth/passkeys/register/finish",
&payload,
Some(&login.access_token),
)
.await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn passkey_login_start_requires_passkey() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let _password = "secret";
app.insert_user("passkey-login", TestUserRole::Owner)
.await?;
let payload = PasskeyLoginStartPayload {
username: "passkey-login".to_string(),
};
let response = app
.post_json("/api/auth/passkeys/login/start", &payload, None)
.await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn passkey_login_start_unknown_user_returns_not_found() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let payload = PasskeyLoginStartPayload {
username: "nobody".to_string(),
};
let response = app
.post_json("/api/auth/passkeys/login/start", &payload, None)
.await?;
assert_eq!(response.status(), StatusCode::NOT_FOUND);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn passkey_login_finish_rejects_invalid_challenge() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let payload = PasskeyLoginFinishPayload {
challenge_id: Uuid::new_v4(),
credential: fake_authentication_credential(),
};
let response = app
.post_json("/api/auth/passkeys/login/finish", &payload, None)
.await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn list_passkeys_returns_entries() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "secret";
let user_id = app
.insert_user("passkey-owner", TestUserRole::Owner)
.await?;
app.insert_passkey(user_id, Some("Laptop")).await?;
let (session, _) = login_with_session(&app, "passkey-owner", password).await?;
let response = app
.get("/api/profile/passkeys", Some(&session.access_token))
.await?;
assert_eq!(response.status(), StatusCode::OK);
let body = body_to_vec(response.into_body()).await?;
let summaries: Vec<PasskeySummary> = serde_json::from_slice(&body)?;
assert_eq!(summaries.len(), 1);
assert_eq!(summaries[0].nickname.as_deref(), Some("Laptop"));
assert!(summaries[0].revoked_at.is_none());
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn delete_passkey_soft_revokes() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "secret";
let user_id = app
.insert_user("passkey-delete", TestUserRole::Owner)
.await?;
let passkey_id = app.insert_passkey(user_id, Some("Phone")).await?;
app.insert_passkey(user_id, Some("Backup")).await?;
let (session, _) = login_with_session(&app, "passkey-delete", password).await?;
let response = app
.delete(
&format!("/api/profile/passkeys/{}?reason=lost", passkey_id),
Some(&session.access_token),
)
.await?;
assert_eq!(response.status(), StatusCode::NO_CONTENT);
app.with_conn(move |conn| {
use papercrate::schema::user_passkeys::dsl as passkey_dsl;
let record = passkey_dsl::user_passkeys
.find(passkey_id)
.first::<UserPasskey>(conn)?;
assert!(record.revoked_at.is_some());
assert_eq!(record.revoked_reason.as_deref(), Some("lost"));
Ok(())
})
.await?;
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn delete_passkey_prevents_last() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "secret";
let user_id = app
.insert_user("passkey-guard", TestUserRole::Owner)
.await?;
let first_id = app.insert_passkey(user_id, Some("Key A")).await?;
let last_id = app.insert_passkey(user_id, Some("Key B")).await?;
let (session, _) = login_with_session(&app, "passkey-guard", password).await?;
let response = app
.delete(
&format!("/api/profile/passkeys/{}", first_id),
Some(&session.access_token),
)
.await?;
assert_eq!(response.status(), StatusCode::NO_CONTENT);
let block_response = app
.delete(
&format!("/api/profile/passkeys/{}", last_id),
Some(&session.access_token),
)
.await?;
assert_eq!(block_response.status(), StatusCode::BAD_REQUEST);
app.cleanup().await?;
Ok(())
}
fn fake_register_credential() -> RegisterPublicKeyCredential {
RegisterPublicKeyCredential {
id: "fake-passkey".to_string(),
raw_id: vec![1, 2, 3, 4].into(),
response: AuthenticatorAttestationResponseRaw {
attestation_object: vec![5, 6, 7, 8].into(),
client_data_json: vec![9, 10, 11, 12].into(),
transports: None,
},
type_: "public-key".to_string(),
extensions: Default::default(),
}
}
fn fake_authentication_credential() -> PublicKeyCredential {
PublicKeyCredential {
id: "fake-auth".to_string(),
raw_id: vec![1, 2, 3].into(),
response: AuthenticatorAssertionResponseRaw {
authenticator_data: vec![4, 5, 6].into(),
client_data_json: vec![7, 8, 9].into(),
signature: vec![10, 11, 12].into(),
user_handle: None,
},
extensions: Default::default(),
type_: "public-key".to_string(),
}
}
#[tokio::test]
async fn login_rejects_invalid_password() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let _password = "valid";
app.insert_user("robin", TestUserRole::Owner).await?;
let payload = json!({ "username": "robin", "password": "wrong" });
let response = app.post_json("/api/auth/login", &payload, None).await?;
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = body_to_vec(response.into_body()).await?;
let err: ApiErrorResponse = serde_json::from_slice(&body)?;
assert_eq!(err.error, "password authentication is no longer supported");
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn refresh_rotates_refresh_token() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "rotate";
app.insert_user("rita", TestUserRole::Owner).await?;
let (login, refresh_cookie) = login_with_session(&app, "rita", password).await?;
let response = app
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie))
.await?;
assert_eq!(response.status(), StatusCode::OK);
let new_cookie = extract_refresh_cookie(response.headers())?;
let body = body_to_vec(response.into_body()).await?;
let refreshed: LoginResponse = serde_json::from_slice(&body)?;
assert_eq!(refreshed.tenant.name, login.tenant.name);
let me_response = app
.get("/api/auth/me", Some(&refreshed.access_token))
.await?;
assert_eq!(me_response.status(), StatusCode::OK);
let retry = app
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie))
.await?;
assert_eq!(retry.status(), StatusCode::UNAUTHORIZED);
// new cookie should differ from old to avoid reuse
assert_ne!(new_cookie, refresh_cookie);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn logout_revokes_refresh_token() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "logout";
app.insert_user("logan", TestUserRole::Owner).await?;
let (login, refresh_cookie) = login_with_session(&app, "logan", password).await?;
let response = app
.post_json_with_cookie(
"/api/auth/logout",
&json!({}),
Some(&login.access_token),
Some(&refresh_cookie),
)
.await?;
assert_eq!(response.status(), StatusCode::NO_CONTENT);
let cleared_cookie = extract_refresh_cookie(response.headers())?;
assert!(cleared_cookie.ends_with("="));
let after_logout = app
.post_json_with_cookie("/api/auth/refresh", &json!({}), None, Some(&refresh_cookie))
.await?;
assert_eq!(after_logout.status(), StatusCode::UNAUTHORIZED);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn me_requires_authentication() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let unauthenticated = app.get("/api/auth/me", None).await?;
assert_eq!(unauthenticated.status(), StatusCode::UNAUTHORIZED);
let invalid = app.get("/api/auth/me", Some("invalid")).await?;
assert_eq!(invalid.status(), StatusCode::UNAUTHORIZED);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn login_returns_tenant_selection_when_multiple_memberships() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "multipass";
let user_id = app.insert_user("multipass", TestUserRole::Owner).await?;
let secondary_name = "secondary".to_string();
let name_for_insert = secondary_name.clone();
let secondary_id = Uuid::new_v4();
app.with_conn(move |conn| {
diesel::insert_into(tenants::table)
.values((
tenants::id.eq(secondary_id),
tenants::name.eq(&name_for_insert),
tenants::status.eq(TenantStatus::Active),
))
.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)
.values(&membership)
.execute(conn)?;
Ok(())
})
.await?;
let (login, refresh_cookie) = login_with_session(&app, "multipass", password).await?;
let tenants_response = app.get("/api/tenants", Some(&login.access_token)).await?;
assert_eq!(tenants_response.status(), StatusCode::OK);
let tenants_body = body_to_vec(tenants_response.into_body()).await?;
let tenant_list: Vec<TenantSummary> = serde_json::from_slice(&tenants_body)?;
assert!(tenant_list.len() >= 2);
let secondary = tenant_list
.iter()
.find(|tenant| tenant.name == secondary_name)
.map(|t| t.id)
.context("secondary tenant missing from listing")?;
let select_response = app
.post_json_with_cookie(
"/api/auth/select-tenant",
&json!({ "tenant_id": secondary }),
Some(&login.access_token),
Some(&refresh_cookie),
)
.await?;
assert_eq!(select_response.status(), StatusCode::OK);
let select_body = body_to_vec(select_response.into_body()).await?;
let rotated: LoginResponse = serde_json::from_slice(&select_body)?;
assert_eq!(rotated.tenant.id, secondary);
assert_eq!(rotated.tenant.name, secondary_name);
app.cleanup().await?;
Ok(())
}
async fn login_with_session(
app: &TestApp,
username: &str,
_password: &str,
) -> Result<(LoginResponse, String)> {
let username = username.to_string();
let state = app.state.clone();
app.with_conn(move |conn| {
use papercrate::schema::user_memberships::dsl as memberships_dsl;
use papercrate::schema::users::dsl as users_dsl;
let user: papercrate::models::User = users_dsl::users
.filter(users_dsl::username.eq(&username))
.first(conn)?;
let membership: papercrate::models::UserMembership = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(user.id))
.first(conn)?;
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(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();
let session_hash = hash_session_token(&session_value);
let refresh_expires_at = now + ChronoDuration::days(state.config.refresh_token_expiry_days);
let new_session = NewUserSession {
id: session_id,
user_id: user.id,
token_hash: session_hash,
issued_at: now.naive_utc(),
expires_at: refresh_expires_at.naive_utc(),
tenant_id: tenant.id,
};
diesel::insert_into(user_sessions::table)
.values(&new_session)
.execute(conn)?;
let login = LoginResponse {
access_token,
tenant: LoginTenant {
id: tenant.id,
name: tenant.name.clone(),
},
};
let cookie = format!("refresh_token={session_value}");
Ok((login, cookie))
})
.await
}
fn extract_refresh_cookie(headers: &axum::http::HeaderMap) -> Result<String> {
let header_value = headers
.get(SET_COOKIE)
.context("missing set-cookie header")?
.to_str()
.context("invalid set-cookie header")?;
let cookie = header_value
.split(';')
.next()
.context("set-cookie missing cookie value")?
.to_string();
Ok(cookie)
}
fn generate_session_token() -> String {
let mut bytes = [0u8; 32];
OsRng
.try_fill_bytes(&mut bytes)
.expect("failed to read random bytes");
hex::encode(bytes)
}
fn hash_session_token(value: &str) -> String {
let mut hasher = Sha256::new();
hasher.update(value.as_bytes());
hex::encode(hasher.finalize())
}
#[tokio::test]
async fn tenant_selection_excludes_inactive_tenants() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let user_id = app.insert_user("tenant-user", TestUserRole::Owner).await?;
let tenant_id = app
.with_conn(|conn| {
let tenant: papercrate::models::Tenant = tenant_dsl::tenants
.filter(tenant_dsl::name.eq("test_tenant"))
.first(conn)?;
Ok::<_, anyhow::Error>(tenant.id)
})
.await?;
app.with_conn(move |conn| {
diesel::update(tenant_dsl::tenants.find(tenant_id))
.set(tenant_dsl::status.eq(TenantStatus::Suspended))
.execute(conn)?;
Ok(())
})
.await?;
let magic_value = "tenant-status-token";
let token_hash = {
let mut hasher = Sha256::new();
hasher.update(magic_value.as_bytes());
hex::encode(hasher.finalize())
};
let user_id_for_token = user_id;
app.with_conn(move |conn| {
let token = MagicToken {
id: Uuid::new_v4(),
user_id: user_id_for_token,
kind: MagicTokenKind::EmailLogin,
token_hash,
metadata: json!({}),
expires_at: (Utc::now() + ChronoDuration::hours(1)).naive_utc(),
max_uses: None,
used_count: 0,
created_at: Utc::now().naive_utc(),
created_by: None,
last_used_at: None,
};
diesel::insert_into(magic_dsl::magic_tokens)
.values(&token)
.execute(conn)?;
Ok(())
})
.await?;
let payload = json!({
"username": "tenant-user",
"magic_token": magic_value,
});
let response = app.post_json("/api/auth/login", &payload, None).await?;
assert_eq!(response.status(), StatusCode::FORBIDDEN);
let body = body_to_vec(response.into_body()).await?;
let err: ApiErrorResponse = serde_json::from_slice(&body)?;
assert_eq!(err.error, "no active tenants available");
app.cleanup().await?;
Ok(())
}
+104
View File
@@ -0,0 +1,104 @@
use anyhow::{anyhow, Result};
use axum::http::StatusCode;
use diesel::prelude::*;
use papercrate::models::ApiCapability;
use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole};
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 = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "limited-docs";
let user_id = app.insert_user("limited-docs", TestUserRole::Owner).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 = 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 = 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 = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "caps-reader";
let user_id = app.insert_user("caps-reader", TestUserRole::Member).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 = body_to_vec(create.into_body()).await?;
assert!(String::from_utf8_lossy(&create_body).contains("missing"));
app.cleanup().await?;
Ok(())
}
+114
View File
@@ -0,0 +1,114 @@
use anyhow::Result;
use axum::http::StatusCode;
use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole};
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", TestUserRole::Owner).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 == "readonly"));
assert!(sets.iter().any(|set| set.slug == "webdav"));
let capabilities_resp = app.get("/api/capabilities", Some(&token)).await?;
assert_eq!(capabilities_resp.status(), StatusCode::OK);
let capabilities_body = body_to_vec(capabilities_resp.into_body()).await?;
let capabilities: Vec<String> = serde_json::from_slice(&capabilities_body)?;
assert!(capabilities.contains(&"documents:read".to_string()));
assert!(capabilities.contains(&"capability_sets:write".to_string()));
assert_eq!(
capabilities.len(),
papercrate::models::ApiCapability::variants().len()
);
// 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(())
}
+1
View File
@@ -0,0 +1 @@
pub use papercrate::test_support::*;
+275
View File
@@ -0,0 +1,275 @@
use anyhow::Result;
use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole};
use serde::Deserialize;
use serde_json::json;
use uuid::Uuid;
#[derive(Deserialize)]
struct DocumentDetail {
document: DocumentSummary,
}
#[derive(Deserialize)]
struct DocumentSummary {
id: Uuid,
#[serde(rename = "title")]
_title: String,
#[serde(default)]
correspondents: Vec<DocumentCorrespondentSummary>,
}
#[derive(Deserialize)]
struct DocumentCorrespondentSummary {
id: Uuid,
name: String,
}
#[derive(Deserialize)]
struct CorrespondentSummary {
id: Uuid,
}
#[derive(Deserialize)]
struct BulkCorrespondentResult {
assigned: usize,
removed: usize,
}
struct TestContext {
app: TestApp,
token: String,
document_ids: Vec<Uuid>,
sender_id: Uuid,
receiver_id: Uuid,
}
impl TestContext {
const SENDER_NAME: &'static str = "Acme Corp";
const RECEIVER_NAME: &'static str = "Bank Ltd";
async fn new(prefix: &str) -> Result<Self> {
let app = TestApp::new().await?;
let username = format!("{prefix}_user");
let password = format!("{prefix}_pw");
app.insert_user(&username, TestUserRole::Owner).await?;
let token = app.login_token(&username, &password).await?;
let first_id =
upload_document(&app, &token, &format!("{prefix}-one.txt"), b"letter one").await?;
let second_id =
upload_document(&app, &token, &format!("{prefix}-two.txt"), b"letter two").await?;
let sender_id = create_correspondent(&app, &token, Self::SENDER_NAME).await?;
let receiver_id = create_correspondent(&app, &token, Self::RECEIVER_NAME).await?;
Ok(Self {
app,
token,
document_ids: vec![first_id, second_id],
sender_id,
receiver_id,
})
}
async fn assign(&self, correspondent_ids: &[Uuid]) -> Result<BulkCorrespondentResult> {
self.assign_with_action(correspondent_ids, None).await
}
async fn assign_with_action(
&self,
correspondent_ids: &[Uuid],
action: Option<&str>,
) -> Result<BulkCorrespondentResult> {
let assignments: Vec<_> = correspondent_ids
.iter()
.map(|id| json!({ "correspondent_id": id }))
.collect();
let mut payload = json!({
"document_ids": self.document_ids,
"assignments": assignments,
});
if let Some(action) = action {
if let Some(obj) = payload.as_object_mut() {
obj.insert("action".to_string(), json!(action));
}
}
let response = self
.app
.post_json(
"/api/documents/bulk/correspondents",
&payload,
Some(&self.token),
)
.await?;
assert!(response.status().is_success());
let body = body_to_vec(response.into_body()).await?;
Ok(serde_json::from_slice(&body)?)
}
async fn fetch_correspondents(
&self,
document_id: Uuid,
) -> Result<Vec<DocumentCorrespondentSummary>> {
let detail = fetch_document_detail(&self.app, &self.token, document_id).await?;
Ok(detail.document.correspondents)
}
async fn create_correspondent(&self, name: &str) -> Result<Uuid> {
create_correspondent(&self.app, &self.token, name).await
}
}
#[tokio::test]
async fn bulk_assign_correspondents_adds_new_links() -> Result<()> {
let _lock = acquire_db_lock().await;
let context = TestContext::new("corresp_add").await?;
let result = context
.assign(&[context.sender_id, context.receiver_id])
.await?;
assert_eq!(result.assigned, 4);
assert_eq!(result.removed, 0);
for doc_id in &context.document_ids {
let correspondents = context.fetch_correspondents(*doc_id).await?;
let names: Vec<_> = correspondents
.iter()
.map(|entry| entry.name.as_str())
.collect();
assert!(names.contains(&TestContext::SENDER_NAME));
assert!(names.contains(&TestContext::RECEIVER_NAME));
let ids: Vec<_> = correspondents.iter().map(|entry| entry.id).collect();
assert!(ids.contains(&context.sender_id));
assert!(ids.contains(&context.receiver_id));
}
context.app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn bulk_assign_correspondents_is_idempotent() -> Result<()> {
let _lock = acquire_db_lock().await;
let context = TestContext::new("corresp_idempotent").await?;
context
.assign(&[context.sender_id, context.receiver_id])
.await?;
let repeat = context
.assign(&[context.sender_id, context.receiver_id])
.await?;
assert_eq!(repeat.assigned, 0);
assert_eq!(repeat.removed, 0);
context.app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn bulk_remove_correspondents_detaches_links() -> Result<()> {
let _lock = acquire_db_lock().await;
let context = TestContext::new("corresp_remove").await?;
context
.assign(&[context.sender_id, context.receiver_id])
.await?;
let removal = context
.assign_with_action(&[context.sender_id], Some("remove"))
.await?;
assert_eq!(removal.assigned, 0);
assert_eq!(removal.removed, 2);
for doc_id in &context.document_ids {
let correspondents = context.fetch_correspondents(*doc_id).await?;
assert_eq!(correspondents.len(), 1);
let entry = &correspondents[0];
assert_eq!(entry.id, context.receiver_id);
assert_eq!(entry.name, TestContext::RECEIVER_NAME);
}
context.app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn bulk_assign_correspondents_appends_new_entries() -> Result<()> {
let _lock = acquire_db_lock().await;
let context = TestContext::new("corresp_append").await?;
context
.assign(&[context.sender_id, context.receiver_id])
.await?;
context
.assign_with_action(&[context.sender_id], Some("remove"))
.await?;
let charlie_name = "Charlie";
let charlie_id = context.create_correspondent(charlie_name).await?;
let add_result = context.assign(&[charlie_id]).await?;
assert_eq!(add_result.assigned, 2);
assert_eq!(add_result.removed, 0);
for doc_id in &context.document_ids {
let correspondents = context.fetch_correspondents(*doc_id).await?;
assert_eq!(correspondents.len(), 2);
let ids: Vec<_> = correspondents.iter().map(|entry| entry.id).collect();
assert!(ids.contains(&context.receiver_id));
assert!(ids.contains(&charlie_id));
let names: Vec<_> = correspondents
.iter()
.map(|entry| entry.name.as_str())
.collect();
assert!(names.contains(&TestContext::RECEIVER_NAME));
assert!(names.contains(&charlie_name));
}
context.app.cleanup().await?;
Ok(())
}
async fn upload_document(
app: &TestApp,
token: &str,
filename: &str,
contents: &[u8],
) -> Result<Uuid> {
let response = app
.upload_document(
"/api/documents",
filename,
"text/plain",
contents,
None,
token,
)
.await?;
assert!(response.status().is_success());
let body = body_to_vec(response.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&body)?;
Ok(detail.document.id)
}
async fn create_correspondent(app: &TestApp, token: &str, name: &str) -> Result<Uuid> {
let response = app
.post_json("/api/correspondents", &json!({ "name": name }), Some(token))
.await?;
assert!(response.status().is_success());
let body = body_to_vec(response.into_body()).await?;
let summary: CorrespondentSummary = serde_json::from_slice(&body)?;
Ok(summary.id)
}
async fn fetch_document_detail(
app: &TestApp,
token: &str,
document_id: Uuid,
) -> Result<DocumentDetail> {
let response = app
.get(&format!("/api/documents/{document_id}"), Some(token))
.await?;
assert!(response.status().is_success());
let body = body_to_vec(response.into_body()).await?;
Ok(serde_json::from_slice(&body)?)
}
+584
View File
@@ -0,0 +1,584 @@
# Derived test cases from the Paperless-ngx project (https://github.com/paperless-ngx/paperless-ngx).
# Copyright (c) Paperless-ngx contributors, licensed under the GNU GPL-3.0.
cases:
- name: date_format_1
parser: parse_date
filename: null
content: "lorem ipsum 130218 lorem ipsum"
settings: {}
expected:
mode: none
- name: date_format_2
parser: parse_date
filename: null
content: "lorem ipsum 2018 lorem ipsum"
settings: {}
expected:
mode: none
- name: date_format_3
parser: parse_date
filename: null
content: "lorem ipsum 20180213 lorem ipsum"
settings: {}
expected:
mode: none
- name: date_format_4
parser: parse_date
filename: null
content: "lorem ipsum 13.02.2018 lorem ipsum"
settings: {}
expected:
mode: single
value: 2018-02-13
- name: date_format_5
parser: parse_date
filename: null
content: "lorem ipsum 130218, 2018, 20180213 and lorem 13.02.2018 lorem ipsum"
settings: {}
expected:
mode: single
value: 2018-02-13
- name: date_format_6
parser: parse_date
filename: null
content: |
lorem ipsum
Wohnort
3100
IBAN
AT87 4534
1234
1234 5678
BIC
lorem ipsum
settings: {}
expected:
mode: none
- name: date_format_7
parser: parse_date
filename: null
content: |
lorem ipsum
März 2019
lorem ipsum
settings:
DATE_PARSER_LANGUAGES:
- de
expected:
mode: single
value: 2019-03-01
- name: date_format_8
parser: parse_date
filename: null
content: |
lorem ipsum
Wohnort
3100
IBAN
AT87 4534
1234
1234 5678
BIC
lorem ipsum
März 2020
settings:
DATE_PARSER_LANGUAGES:
- de
expected:
mode: single
value: 2020-03-01
- name: date_format_9
parser: parse_date
filename: null
content: |
lorem ipsum
27. Nullmonth 2020
März 2020
lorem ipsum
settings:
DATE_PARSER_LANGUAGES:
- de
expected:
mode: single
value: 2020-03-01
- name: date_format_10
parser: parse_date
filename: null
content: "Customer Number Currency 22-MAR-2022 Credit Card 1934829304"
settings: {}
expected:
mode: single
value: 2022-03-22
- name: date_format_11
parser: parse_date
filename: null
content: "Customer Number Currency 22 MAR 2022 Credit Card 1934829304"
settings: {}
expected:
mode: single
value: 2022-03-22
- name: date_format_12
parser: parse_date
filename: null
content: "Customer Number Currency 22/MAR/2022 Credit Card 1934829304"
settings: {}
expected:
mode: single
value: 2022-03-22
- name: date_format_13
parser: parse_date
filename: null
content: "Customer Number Currency 22.MAR.2022 Credit Card 1934829304"
settings: {}
expected:
mode: single
value: 2022-03-22
- name: date_format_14
parser: parse_date
filename: null
content: "Customer Number Currency 22.MAR 2022 Credit Card 1934829304"
settings: {}
expected:
mode: single
value: 2022-03-22
- name: date_format_15
parser: parse_date
filename: null
content: "Customer Number Currency 22.MAR.22 Credit Card 1934829304"
settings: {}
expected:
mode: none
- name: date_format_16
parser: parse_date
filename: null
content: "Customer Number Currency 22.MAR,22 Credit Card 1934829304"
settings: {}
expected:
mode: none
- name: date_format_17
parser: parse_date
filename: null
content: "Customer Number Currency 22,MAR,2022 Credit Card 1934829304"
settings: {}
expected:
mode: none
- name: date_format_18
parser: parse_date
filename: null
content: "Customer Number Currency 22 MAR,2022 Credit Card 1934829304"
settings: {}
expected:
mode: none
- name: date_format_19
parser: parse_date
filename: null
content: "Customer Number Currency 21st MAR 2022 Credit Card 1934829304"
settings: {}
expected:
mode: single
value: 2022-03-21
- name: date_format_20
parser: parse_date
filename: null
content: "Customer Number Currency 22nd March 2022 Credit Card 1934829304"
settings: {}
expected:
mode: single
value: 2022-03-22
- name: date_format_21
parser: parse_date
filename: null
content: "Customer Number Currency 2nd MAR 2022 Credit Card 1934829304"
settings: {}
expected:
mode: single
value: 2022-03-02
- name: date_format_22
parser: parse_date
filename: null
content: "Customer Number Currency 23rd MAR 2022 Credit Card 1934829304"
settings: {}
expected:
mode: single
value: 2022-03-23
- name: date_format_23
parser: parse_date
filename: null
content: "Customer Number Currency 24th MAR 2022 Credit Card 1934829304"
settings: {}
expected:
mode: single
value: 2022-03-24
- name: date_format_24
parser: parse_date
filename: null
content: "Customer Number Currency 21-MAR-2022 Credit Card 1934829304"
settings: {}
expected:
mode: single
value: 2022-03-21
- name: date_format_25
parser: parse_date
filename: null
content: "Customer Number Currency 25TH MAR 2022 Credit Card 1934829304"
settings: {}
expected:
mode: single
value: 2022-03-25
- name: date_format_26
parser: parse_date
filename: null
content: "CHASE 0 September 25, 2019 JPMorgan Chase Bank, NA. P0 Box 182051"
settings: {}
expected:
mode: single
value: 2019-09-25
- name: numeric_mdy_slash
parser: parse_date
filename: null
content: "03/17/2008"
settings:
DATE_ORDER: MDY
expected:
mode: single
value: 2008-03-17
- name: crazy_date_past
parser: parse_date
filename: null
content: "01-07-0590 00:00:00"
settings: {}
expected:
mode: none
- name: crazy_date_future
parser: parse_date
filename: null
content: "01-07-2350 00:00:00"
settings: {}
expected:
mode: none
- name: crazy_date_with_spaces
parser: parse_date
filename: null
content: "20 408000l 2475"
settings: {}
expected:
mode: none
- name: utf_month_names_decembre
parser: parse_date
filename: null
content: "13 décembre 2023"
settings:
DATE_PARSER_LANGUAGES:
- fr
- de
- hr
- cs
- pl
- tr
expected:
mode: single
value: 2023-12-13
- name: utf_month_names_aout
parser: parse_date
filename: null
content: "13 août 2022"
settings:
DATE_PARSER_LANGUAGES:
- fr
- de
- hr
- cs
- pl
- tr
expected:
mode: single
value: 2022-08-13
- name: utf_month_names_marz
parser: parse_date
filename: null
content: "11 März 2020"
settings:
DATE_PARSER_LANGUAGES:
- fr
- de
- hr
- cs
- pl
- tr
expected:
mode: single
value: 2020-03-11
- name: utf_month_names_ozujka
parser: parse_date
filename: null
content: "17. ožujka 2018."
settings:
DATE_PARSER_LANGUAGES:
- fr
- de
- hr
- cs
- pl
- tr
expected:
mode: single
value: 2018-03-17
- name: utf_month_names_veljace
parser: parse_date
filename: null
content: "1. veljače 2016."
settings:
DATE_PARSER_LANGUAGES:
- fr
- de
- hr
- cs
- pl
- tr
expected:
mode: single
value: 2016-02-01
- name: utf_month_names_unora
parser: parse_date
filename: null
content: "15. února 1985"
settings:
DATE_PARSER_LANGUAGES:
- fr
- de
- hr
- cs
- pl
- tr
expected:
mode: single
value: 1985-02-15
- name: utf_month_names_zari
parser: parse_date
filename: null
content: "30. září 2011"
settings:
DATE_PARSER_LANGUAGES:
- fr
- de
- hr
- cs
- pl
- tr
expected:
mode: single
value: 2011-09-30
- name: utf_month_names_kvetna
parser: parse_date
filename: null
content: "28. května 1990"
settings:
DATE_PARSER_LANGUAGES:
- fr
- de
- hr
- cs
- pl
- tr
expected:
mode: single
value: 1990-05-28
- name: utf_month_names_grudzien
parser: parse_date
filename: null
content: "1. grudzień 1997"
settings:
DATE_PARSER_LANGUAGES:
- fr
- de
- hr
- cs
- pl
- tr
expected:
mode: single
value: 1997-12-01
- name: utf_month_names_subat
parser: parse_date
filename: null
content: "17 Şubat 2024"
settings:
DATE_PARSER_LANGUAGES:
- fr
- de
- hr
- cs
- pl
- tr
expected:
mode: single
value: 2024-02-17
- name: utf_month_names_agustos
parser: parse_date
filename: null
content: "30 Ağustos 2012"
settings:
DATE_PARSER_LANGUAGES:
- fr
- de
- hr
- cs
- pl
- tr
expected:
mode: single
value: 2012-08-30
- name: utf_month_names_eylul
parser: parse_date
filename: null
content: "17 Eylül 2000"
settings:
DATE_PARSER_LANGUAGES:
- fr
- de
- hr
- cs
- pl
- tr
expected:
mode: single
value: 2000-09-17
- name: utf_month_names_oktober
parser: parse_date
filename: null
content: "5. október 1992"
settings:
DATE_PARSER_LANGUAGES:
- fr
- de
- hr
- cs
- pl
- tr
- hu
expected:
mode: single
value: 1992-10-05
- name: multiple_dates
parser: parse_date_generator
filename: null
content: |
This text has multiple dates.
For example 02.02.2018, 22 July 2022 and December 2021.
But not 24-12-9999 because it's in the future...
settings: {}
expected:
mode: multiple
value:
- 2018-02-02
- 2022-07-22
- 2021-12-01
- name: filename_date_parse_valid_ymd
parser: parse_date
filename: /tmp/Scan-2022-04-01.pdf
content: "No date in here"
settings:
FILENAME_DATE_ORDER: YMD
expected:
mode: single
value: 2022-04-01
- name: filename_date_parse_valid_dmy
parser: parse_date
filename: /tmp/Scan-10.01.2021.pdf
content: "No date in here"
settings:
FILENAME_DATE_ORDER: DMY
expected:
mode: single
value: 2021-01-10
- name: filename_date_parse_invalid
parser: parse_date
filename: "/tmp/20 408000l 2475 - test.pdf"
content: "No date in here"
settings:
FILENAME_DATE_ORDER: YMD
expected:
mode: none
- name: filename_date_ignored_use_content
parser: parse_date
filename: /tmp/Scan-2022-04-01.pdf
content: "The matching date is 24.03.2022"
settings:
FILENAME_DATE_ORDER: YMD
IGNORE_DATES:
- 2022-04-01
expected:
mode: single
value: 2022-03-24
- name: ignored_dates_default_order
parser: parse_date
filename: null
content: "lorem ipsum 110319, 20200117 and lorem 13.02.2018 lorem ipsum"
settings:
IGNORE_DATES:
- 2019-11-03
- 2020-01-17
expected:
mode: single
value: 2018-02-13
- name: ignored_dates_order_ymd
parser: parse_date
filename: null
content: "lorem ipsum 190311, 20200117 and lorem 13.02.2018 lorem ipsum"
settings:
FILENAME_DATE_ORDER: YMD
IGNORE_DATES:
- 2019-11-03
- 2020-01-17
expected:
mode: single
value: 2018-02-13
File diff suppressed because it is too large Load Diff
+84
View File
@@ -0,0 +1,84 @@
use anyhow::Result;
use axum::http::StatusCode;
use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole};
use serde::Deserialize;
#[derive(Clone, Deserialize)]
struct DocumentDetail {
document: DocumentInfo,
}
#[derive(Clone, Deserialize)]
struct DocumentInfo {
current_version: Option<DocumentVersion>,
}
#[derive(Clone, Deserialize)]
struct DocumentVersion {
download: DownloadLink,
}
#[derive(Clone, Deserialize)]
struct DownloadLink {
url: String,
expires_at: i64,
}
#[tokio::test]
async fn document_download_redirects_when_proxy_disabled() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let username = "download-user";
let password = "secret";
app.insert_user(username, TestUserRole::Owner).await?;
let token = app.login_token(username, password).await?;
let upload = app
.upload_document(
"/api/documents",
"download.pdf",
"application/pdf",
b"dummy",
None,
&token,
)
.await?;
assert_eq!(upload.status(), StatusCode::CREATED);
let body = body_to_vec(upload.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&body)?;
let download_link = detail
.document
.current_version
.as_ref()
.expect("missing version")
.download
.clone();
assert!(download_link.expires_at > 0);
let download_path = download_link.url.clone();
let redirect = app.get(&download_path, None).await?;
assert_eq!(redirect.status(), StatusCode::TEMPORARY_REDIRECT);
let location = redirect
.headers()
.get("location")
.expect("redirect location header")
.to_str()
.expect("location utf8");
assert!(location.starts_with("https://fake-storage/"));
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn download_with_invalid_token_is_rejected() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let response = app.get("/api/download/not-a-token", None).await?;
assert_eq!(response.status(), StatusCode::UNAUTHORIZED);
app.cleanup().await?;
Ok(())
}
+562
View File
@@ -0,0 +1,562 @@
use anyhow::Result;
use axum::http::StatusCode;
use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole};
use serde::Deserialize;
use serde::Serialize;
use serde_json::json;
use uuid::Uuid;
#[derive(Deserialize)]
struct FolderResponse {
folder: FolderInfo,
}
#[derive(Deserialize)]
struct FolderInfo {
id: Uuid,
name: String,
parent_id: Option<Uuid>,
}
#[derive(Deserialize)]
struct FolderContents {
folder: Option<FolderInfo>,
subfolders: Vec<FolderInfo>,
documents: Vec<DocSummary>,
}
#[derive(Deserialize)]
struct DocSummary {
id: Uuid,
}
#[derive(Deserialize)]
struct FolderTreeNodeResponse {
name: String,
children: Vec<FolderTreeNodeResponse>,
}
#[derive(Serialize)]
struct CreateFolder<'a> {
name: &'a str,
parent_id: Option<Uuid>,
}
#[derive(Serialize)]
struct EnsureFolderPath<'a> {
parent_id: Option<Uuid>,
segments: &'a [&'a str],
}
#[derive(Serialize)]
struct MoveDocumentRequest {
folder_id: Option<Uuid>,
}
#[derive(Deserialize)]
struct DocumentDetail {
document: DocSummary,
}
#[tokio::test]
async fn folder_move_and_delete_flow() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "folderpass";
app.insert_user("folder-admin", TestUserRole::Owner).await?;
let token = app.login_token("folder-admin", password).await?;
let folder_resp = app
.post_json(
"/api/folders",
&CreateFolder {
name: "Projects",
parent_id: None,
},
Some(&token),
)
.await?;
assert_eq!(folder_resp.status(), StatusCode::CREATED);
let folder_body = body_to_vec(folder_resp.into_body()).await?;
let folder: FolderResponse = serde_json::from_slice(&folder_body)?;
let upload = app
.upload_document(
"/api/documents",
"plan.pdf",
"application/pdf",
b"dummy",
None,
&token,
)
.await?;
let upload_body = body_to_vec(upload.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&upload_body)?;
let move_resp = app
.patch_json(
&format!("/api/documents/{}/folder", detail.document.id),
&MoveDocumentRequest {
folder_id: Some(folder.folder.id),
},
Some(&token),
)
.await?;
assert_eq!(move_resp.status(), StatusCode::NO_CONTENT);
let contents = app
.get(
&format!("/api/folders/{}/contents", folder.folder.id),
Some(&token),
)
.await?;
assert_eq!(contents.status(), StatusCode::OK);
let contents_body = body_to_vec(contents.into_body()).await?;
let contents: FolderContents = serde_json::from_slice(&contents_body)?;
assert_eq!(contents.documents.len(), 1);
assert_eq!(contents.documents[0].id, detail.document.id);
let root_contents = app.get("/api/folders/root/contents", Some(&token)).await?;
let root_body = body_to_vec(root_contents.into_body()).await?;
let root: FolderContents = serde_json::from_slice(&root_body)?;
assert!(root
.documents
.iter()
.all(|doc| doc.id != detail.document.id));
let delete_attempt = app
.delete(&format!("/api/folders/{}", folder.folder.id), Some(&token))
.await?;
assert_eq!(delete_attempt.status(), StatusCode::BAD_REQUEST);
let move_back = app
.patch_json(
&format!("/api/documents/{}/folder", detail.document.id),
&MoveDocumentRequest { folder_id: None },
Some(&token),
)
.await?;
assert_eq!(move_back.status(), StatusCode::NO_CONTENT);
let delete = app
.delete(&format!("/api/folders/{}", folder.folder.id), Some(&token))
.await?;
assert_eq!(delete.status(), StatusCode::NO_CONTENT);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn folder_tree_lists_hierarchy() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "folderpass";
app.insert_user("folder-tree", TestUserRole::Owner).await?;
let token = app.login_token("folder-tree", password).await?;
let alpha_resp = app
.post_json(
"/api/folders",
&CreateFolder {
name: "Alpha",
parent_id: None,
},
Some(&token),
)
.await?;
assert_eq!(alpha_resp.status(), StatusCode::CREATED);
let alpha_body = body_to_vec(alpha_resp.into_body()).await?;
let alpha: FolderResponse = serde_json::from_slice(&alpha_body)?;
let archive_resp = app
.post_json(
"/api/folders",
&CreateFolder {
name: "Archive",
parent_id: Some(alpha.folder.id),
},
Some(&token),
)
.await?;
assert_eq!(archive_resp.status(), StatusCode::CREATED);
let beta_resp = app
.post_json(
"/api/folders",
&CreateFolder {
name: "Beta",
parent_id: None,
},
Some(&token),
)
.await?;
assert_eq!(beta_resp.status(), StatusCode::CREATED);
let tree_resp = app.get("/api/folders/tree", Some(&token)).await?;
assert_eq!(tree_resp.status(), StatusCode::OK);
let tree_body = body_to_vec(tree_resp.into_body()).await?;
let tree: Vec<FolderTreeNodeResponse> = serde_json::from_slice(&tree_body)?;
assert_eq!(tree.len(), 2);
assert_eq!(tree[0].name, "Alpha");
assert_eq!(tree[0].children.len(), 1);
assert_eq!(tree[0].children[0].name, "Archive");
assert!(tree[0].children[0].children.is_empty());
assert_eq!(tree[1].name, "Beta");
assert!(tree[1].children.is_empty());
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn update_folder_parent_to_root() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "rootpass";
app.insert_user("root-admin", TestUserRole::Owner).await?;
let token = app.login_token("root-admin", password).await?;
// Create a parent folder under root
let parent_resp = app
.post_json(
"/api/folders",
&CreateFolder {
name: "Parent",
parent_id: None,
},
Some(&token),
)
.await?;
assert_eq!(parent_resp.status(), StatusCode::CREATED);
let parent_body = body_to_vec(parent_resp.into_body()).await?;
let parent: FolderResponse = serde_json::from_slice(&parent_body)?;
// Create a child folder inside the parent
let child_resp = app
.post_json(
"/api/folders",
&CreateFolder {
name: "Child",
parent_id: Some(parent.folder.id),
},
Some(&token),
)
.await?;
assert_eq!(child_resp.status(), StatusCode::CREATED);
let child_body = body_to_vec(child_resp.into_body()).await?;
let child: FolderResponse = serde_json::from_slice(&child_body)?;
// Move the child back to the root by setting parent_id to null
let update_resp = app
.patch_json(
&format!("/api/folders/{}", child.folder.id),
&json!({ "parent_id": null }),
Some(&token),
)
.await?;
assert_eq!(update_resp.status(), StatusCode::NO_CONTENT);
// Fetch the child folder and ensure parent_id is now null
let updated_resp = app
.get(&format!("/api/folders/{}", child.folder.id), Some(&token))
.await?;
assert_eq!(updated_resp.status(), StatusCode::OK);
let updated_body = body_to_vec(updated_resp.into_body()).await?;
let updated_folder: FolderResponse = serde_json::from_slice(&updated_body)?;
assert!(updated_folder.folder.parent_id.is_none());
// Root contents should include the child folder by name
let root_contents = app.get("/api/folders/root/contents", Some(&token)).await?;
assert_eq!(root_contents.status(), StatusCode::OK);
let root_body = body_to_vec(root_contents.into_body()).await?;
let root: FolderContents = serde_json::from_slice(&root_body)?;
assert!(root
.subfolders
.iter()
.any(|folder| folder.id == child.folder.id));
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn ensure_path_creates_nested_folders() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "pathpass";
app.insert_user("path-admin", TestUserRole::Owner).await?;
let token = app.login_token("path-admin", password).await?;
let base_path = EnsureFolderPath {
parent_id: None,
segments: &["Team", "Engineering", "Backend"],
};
let first_resp = app
.post_json("/api/folders/path", &base_path, Some(&token))
.await?;
assert!(first_resp.status().is_success());
let first_body = body_to_vec(first_resp.into_body()).await?;
let first_folder: FolderResponse = serde_json::from_slice(&first_body)?;
let second_resp = app
.post_json("/api/folders/path", &base_path, Some(&token))
.await?;
assert!(second_resp.status().is_success());
let second_body = body_to_vec(second_resp.into_body()).await?;
let second_folder: FolderResponse = serde_json::from_slice(&second_body)?;
assert_eq!(second_folder.folder.id, first_folder.folder.id);
let engineering_resp = app
.post_json(
"/api/folders/path",
&EnsureFolderPath {
parent_id: None,
segments: &["Team", "Engineering"],
},
Some(&token),
)
.await?;
assert_eq!(engineering_resp.status(), StatusCode::OK);
let engineering_body = body_to_vec(engineering_resp.into_body()).await?;
let engineering_folder: FolderResponse = serde_json::from_slice(&engineering_body)?;
assert_ne!(engineering_folder.folder.id, first_folder.folder.id);
let infra_resp = app
.post_json(
"/api/folders/path",
&EnsureFolderPath {
parent_id: Some(engineering_folder.folder.id),
segments: &["Infrastructure"],
},
Some(&token),
)
.await?;
assert_eq!(infra_resp.status(), StatusCode::OK);
let infra_body = body_to_vec(infra_resp.into_body()).await?;
let infra_folder: FolderResponse = serde_json::from_slice(&infra_body)?;
assert_ne!(infra_folder.folder.id, engineering_folder.folder.id);
let infra_dupe_resp = app
.post_json(
"/api/folders/path",
&EnsureFolderPath {
parent_id: Some(engineering_folder.folder.id),
segments: &["Infrastructure"],
},
Some(&token),
)
.await?;
assert_eq!(infra_dupe_resp.status(), StatusCode::OK);
let infra_dupe_body = body_to_vec(infra_dupe_resp.into_body()).await?;
let infra_dupe_folder: FolderResponse = serde_json::from_slice(&infra_dupe_body)?;
assert_eq!(infra_dupe_folder.folder.id, infra_folder.folder.id);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn create_folder_is_idempotent() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "idempotent";
app.insert_user("folders-idem", TestUserRole::Owner).await?;
let token = app.login_token("folders-idem", password).await?;
let payload = CreateFolder {
name: "Archive",
parent_id: None,
};
let first_resp = app
.post_json("/api/folders", &payload, Some(&token))
.await?;
assert_eq!(first_resp.status(), StatusCode::CREATED);
let first_body = body_to_vec(first_resp.into_body()).await?;
let first_folder: FolderResponse = serde_json::from_slice(&first_body)?;
let second_resp = app
.post_json("/api/folders", &payload, Some(&token))
.await?;
assert_eq!(second_resp.status(), StatusCode::OK);
let second_body = body_to_vec(second_resp.into_body()).await?;
let second_folder: FolderResponse = serde_json::from_slice(&second_body)?;
assert_eq!(first_folder.folder.id, second_folder.folder.id);
let root_contents = app.get("/api/folders/root/contents", Some(&token)).await?;
let root_body = body_to_vec(root_contents.into_body()).await?;
let root: FolderContents = serde_json::from_slice(&root_body)?;
let occurrences = root
.subfolders
.iter()
.filter(|folder| folder.id == first_folder.folder.id)
.count();
assert_eq!(occurrences, 1);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn ensure_folder_path_is_idempotent() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "pathpass";
app.insert_user("path-admin", TestUserRole::Owner).await?;
let token = app.login_token("path-admin", password).await?;
let segments = ["500 Immobilien", "501 Kreuzweg 2", "501.01 Rechtliches"];
let payload = EnsureFolderPath {
parent_id: None,
segments: &segments,
};
let first_resp = app
.post_json("/api/folders/path", &payload, Some(&token))
.await?;
assert!(first_resp.status().is_success());
let first_body = body_to_vec(first_resp.into_body()).await?;
let first_folder: FolderResponse = serde_json::from_slice(&first_body)?;
let second_resp = app
.post_json("/api/folders/path", &payload, Some(&token))
.await?;
assert!(second_resp.status().is_success());
let second_body = body_to_vec(second_resp.into_body()).await?;
let second_folder: FolderResponse = serde_json::from_slice(&second_body)?;
assert_eq!(first_folder.folder.id, second_folder.folder.id);
// Verify intermediate folders are not duplicated
let root_contents = app.get("/api/folders/root/contents", Some(&token)).await?;
let root_body = body_to_vec(root_contents.into_body()).await?;
let root: FolderContents = serde_json::from_slice(&root_body)?;
let root_occurrences = root
.subfolders
.iter()
.filter(|folder| folder.name == segments[0])
.count();
assert_eq!(root_occurrences, 1);
let level_one = root
.subfolders
.iter()
.find(|folder| folder.name == segments[0])
.map(|folder| folder.id)
.expect("root segment not created");
let level_one_contents = app
.get(
&format!("/api/folders/{}/contents", level_one),
Some(&token),
)
.await?;
let level_one_body = body_to_vec(level_one_contents.into_body()).await?;
let level_one_folders: FolderContents = serde_json::from_slice(&level_one_body)?;
let level_one_occurrences = level_one_folders
.subfolders
.iter()
.filter(|folder| folder.name == segments[1])
.count();
assert_eq!(level_one_occurrences, 1);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn folder_rename_updates_name_and_child_paths() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "renamepass";
app.insert_user("rename-admin", TestUserRole::Owner).await?;
let token = app.login_token("rename-admin", password).await?;
let parent_resp = app
.post_json(
"/api/folders",
&CreateFolder {
name: "Projects",
parent_id: None,
},
Some(&token),
)
.await?;
assert_eq!(parent_resp.status(), StatusCode::CREATED);
let parent_body = body_to_vec(parent_resp.into_body()).await?;
let parent: FolderResponse = serde_json::from_slice(&parent_body)?;
let child_resp = app
.post_json(
"/api/folders",
&CreateFolder {
name: "Q1",
parent_id: Some(parent.folder.id),
},
Some(&token),
)
.await?;
assert_eq!(child_resp.status(), StatusCode::CREATED);
let child_body = body_to_vec(child_resp.into_body()).await?;
let child: FolderResponse = serde_json::from_slice(&child_body)?;
let rename_resp = app
.patch_json(
&format!("/api/folders/{}", parent.folder.id),
&json!({ "name": "Archive" }),
Some(&token),
)
.await?;
assert_eq!(rename_resp.status(), StatusCode::NO_CONTENT);
let root_contents = app.get("/api/folders/root/contents", Some(&token)).await?;
assert_eq!(root_contents.status(), StatusCode::OK);
let root_body = body_to_vec(root_contents.into_body()).await?;
let root: FolderContents = serde_json::from_slice(&root_body)?;
let renamed = root
.subfolders
.iter()
.find(|f| f.id == parent.folder.id)
.expect("renamed folder present");
assert_eq!(renamed.name, "Archive");
let folders_only = app
.get(
&format!(
"/api/folders/{}/contents?include_documents=false",
parent.folder.id
),
Some(&token),
)
.await?;
assert_eq!(folders_only.status(), StatusCode::OK);
let folders_only_body = body_to_vec(folders_only.into_body()).await?;
let folders_only_contents: FolderContents = serde_json::from_slice(&folders_only_body)?;
assert!(folders_only_contents.documents.is_empty());
let child_contents = app
.get(
&format!("/api/folders/{}/contents", child.folder.id),
Some(&token),
)
.await?;
assert_eq!(child_contents.status(), StatusCode::OK);
let child_contents_body = body_to_vec(child_contents.into_body()).await?;
let child_details: FolderContents = serde_json::from_slice(&child_contents_body)?;
let child_folder = child_details.folder.expect("child folder info");
assert_eq!(child_folder.name, "Q1");
app.cleanup().await?;
Ok(())
}
+271
View File
@@ -0,0 +1,271 @@
use anyhow::{anyhow, Result};
use axum::http::StatusCode;
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,
users::dsl as users_dsl,
};
use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole};
use serde::Deserialize;
use serde::Serialize;
use uuid::Uuid;
#[derive(Serialize)]
struct CreateTagPayload<'a> {
label: &'a str,
color: Option<&'a str>,
}
#[derive(Deserialize)]
struct DocumentDetail {
document: DocumentInfo,
}
#[derive(Deserialize)]
struct DocumentInfo {
id: Uuid,
tags: Vec<TagInfo>,
}
#[derive(Deserialize)]
struct TagInfo {
label: String,
#[allow(dead_code)]
color: Option<String>,
}
#[derive(Deserialize)]
struct TagResponse {
id: Uuid,
label: String,
color: Option<String>,
usage_count: i64,
}
#[derive(Serialize)]
struct AssignTagsRequest {
tag_ids: Vec<Uuid>,
}
#[tokio::test]
async fn tag_assignment_flow() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "tagpass";
app.insert_user("tagger", TestUserRole::Owner).await?;
let token = app.login_token("tagger", password).await?;
let upload = app
.upload_document(
"/api/documents",
"tagged.txt",
"text/plain",
b"tag me",
None,
&token,
)
.await?;
assert_eq!(upload.status(), StatusCode::CREATED);
let upload_body = body_to_vec(upload.into_body()).await?;
let detail: DocumentDetail = serde_json::from_slice(&upload_body)?;
let create_tag = app
.post_json(
"/api/tags",
&CreateTagPayload {
label: "Important",
color: Some("#FF0000"),
},
Some(&token),
)
.await?;
assert_eq!(create_tag.status(), StatusCode::OK);
let body = body_to_vec(create_tag.into_body()).await?;
let tag: TagResponse = serde_json::from_slice(&body)?;
assert_eq!(tag.label, "Important");
assert_eq!(tag.color.as_deref(), Some("#FF0000"));
assert_eq!(tag.usage_count, 0);
let update = app
.patch_json(
&format!("/api/tags/{}", tag.id),
&serde_json::json!({
"label": "Critical",
"color": "#00FF00"
}),
Some(&token),
)
.await?;
let updated_status = update.status();
let updated_body = body_to_vec(update.into_body()).await?;
if updated_status != StatusCode::OK {
panic!(
"update tag failed: {}",
String::from_utf8_lossy(&updated_body)
);
}
let updated: TagResponse = serde_json::from_slice(&updated_body)?;
assert_eq!(updated.label, "Critical");
assert_eq!(updated.color.as_deref(), Some("#00FF00"));
assert_eq!(updated.usage_count, 0);
let clear_color = app
.patch_json(
&format!("/api/tags/{}", tag.id),
&serde_json::json!({
"color": null
}),
Some(&token),
)
.await?;
let cleared_status = clear_color.status();
let cleared_body = body_to_vec(clear_color.into_body()).await?;
if cleared_status != StatusCode::OK {
panic!(
"clear color failed: {}",
String::from_utf8_lossy(&cleared_body)
);
}
let cleared: TagResponse = serde_json::from_slice(&cleared_body)?;
assert_eq!(cleared.color, None);
assert_eq!(cleared.usage_count, 0);
let assign = app
.post_json(
&format!("/api/documents/{}/tags", detail.document.id),
&AssignTagsRequest {
tag_ids: vec![tag.id],
},
Some(&token),
)
.await?;
assert_eq!(assign.status(), StatusCode::NO_CONTENT);
let refreshed = app
.get(
&format!("/api/documents/{}", detail.document.id),
Some(&token),
)
.await?;
assert_eq!(refreshed.status(), StatusCode::OK);
let refreshed_body = body_to_vec(refreshed.into_body()).await?;
let refreshed_detail: DocumentDetail = serde_json::from_slice(&refreshed_body)?;
assert_eq!(refreshed_detail.document.tags.len(), 1);
assert_eq!(refreshed_detail.document.tags[0].label, "Critical");
let remove = app
.delete(
&format!("/api/documents/{}/tags/{}", detail.document.id, tag.id),
Some(&token),
)
.await?;
assert_eq!(remove.status(), StatusCode::NO_CONTENT);
let final_check = app
.get(
&format!("/api/documents/{}", detail.document.id),
Some(&token),
)
.await?;
let final_body = body_to_vec(final_check.into_body()).await?;
let final_detail: DocumentDetail = serde_json::from_slice(&final_body)?;
assert!(final_detail.document.tags.is_empty());
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn tags_are_isolated_between_tenants() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password_a = "tenant-a";
app.insert_user("alice", TestUserRole::Owner).await?;
let token_a = app.login_token("alice", password_a).await?;
let shared_label = "Shared Label";
let create_a = app
.post_json(
"/api/tags",
&CreateTagPayload {
label: shared_label,
color: Some("#123456"),
},
Some(&token_a),
)
.await?;
assert_eq!(create_a.status(), StatusCode::OK);
let tenant_b_id = Uuid::new_v4();
let user_b_id = Uuid::new_v4();
app.with_conn(move |conn| {
let storage_root = format!("test-tenants/{tenant_b_id}/");
diesel::insert_into(tenants_dsl::tenants)
.values((
tenants_dsl::id.eq(tenant_b_id),
tenants_dsl::name.eq("tenant-b"),
tenants_dsl::storage_root.eq(Some(storage_root)),
tenants_dsl::status.eq(TenantStatus::Active),
))
.execute(conn)?;
let new_user = NewUser {
id: user_b_id,
username: "bob".to_string(),
};
diesel::insert_into(users_dsl::users)
.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)
.execute(conn)?;
Ok::<_, anyhow::Error>(())
})
.await?;
let token_b = app.login_token("bob", "").await?;
let create_b = app
.post_json(
"/api/tags",
&CreateTagPayload {
label: shared_label,
color: Some("#654321"),
},
Some(&token_b),
)
.await?;
assert_eq!(create_b.status(), StatusCode::OK);
app.with_conn(move |conn| {
let tags: Vec<Tag> = tags_dsl::tags
.filter(tags_dsl::label.eq(shared_label))
.order(tags_dsl::tenant_id.asc())
.load(conn)?;
assert_eq!(tags.len(), 2);
assert_ne!(tags[0].tenant_id, tags[1].tenant_id);
Ok::<_, anyhow::Error>(())
})
.await?;
Ok(())
}
+715
View File
@@ -0,0 +1,715 @@
use anyhow::{anyhow, bail, Result};
use axum::http::StatusCode;
use chrono::{Duration as ChronoDuration, Utc};
use diesel::dsl::{count_star, exists, select};
use diesel::prelude::*;
use papercrate::jobs::{
enqueue_job, mark_job_failed, mark_job_succeeded, JOB_DELETE_TENANT, STATUS_FAILED,
STATUS_SUCCEEDED,
};
use papercrate::models::TenantStatus;
use papercrate::schema::{documents, jobs, tenants, user_memberships};
use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole};
use papercrate::workers::tenants::{
build_delete_proof_message, sign_delete_proof, DeleteAction, DeleteTenantJob,
};
use papercrate::workers::{JobExecution, JobHandler};
use serde_json::{json, Value};
use std::sync::Arc;
use uuid::Uuid;
#[tokio::test]
async fn delete_tenant_job_keeps_tenant_when_requested() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "delete-keep";
app.insert_user("tenant-keep", TestUserRole::Owner).await?;
let token = app.login_token("tenant-keep", password).await?;
upload_fixture(&app, &token, "keep.pdf", b"keep").await?;
let tenant_id = default_tenant_id(&app)?;
set_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?;
assert_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?;
let storage = tenant_storage(&app, tenant_id)?;
let storage_prefix = storage.root_prefix().to_string();
let before = tenant_snapshot(&app, tenant_id, &storage_prefix).await?;
assert_eq!(before.doc_count, 1);
assert!(before.membership_count > 0);
assert!(!before.storage_keys.is_empty());
assert_storage_keys_present(&app, &before.storage_keys).await?;
let job = enqueue_delete_job(&app, tenant_id, false).await?;
let job_id = job.id;
let handler = DeleteTenantJob::new();
let state = Arc::new(app.state.clone());
let execution = handler.handle(state, job, storage).await;
assert_job_success(&execution);
record_job_outcome(&app, job_id, &execution).await?;
assert_eq!(fetch_job_status(&app, job_id).await?, STATUS_SUCCEEDED);
let after = tenant_snapshot(&app, tenant_id, &storage_prefix).await?;
assert_eq!(after.doc_count, 0);
assert!(after.membership_count > 0);
assert_storage_keys_absent(&app, &before.storage_keys).await?;
assert_eq!(storage_object_count(&app, &storage_prefix).await?, 0);
assert_eq!(
fetch_tenant_status(&app, tenant_id).await?,
TenantStatus::Suspended
);
assert!(tenant_exists(&app, tenant_id).await?);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn delete_tenant_job_can_reset_tenant_to_active() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "delete-reset";
app.insert_user("tenant-reset", TestUserRole::Owner).await?;
let token = app.login_token("tenant-reset", password).await?;
upload_fixture(&app, &token, "reset.pdf", b"reset").await?;
let tenant_id = default_tenant_id(&app)?;
set_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?;
assert_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?;
let storage = tenant_storage(&app, tenant_id)?;
let storage_prefix = storage.root_prefix().to_string();
let before = tenant_snapshot(&app, tenant_id, &storage_prefix).await?;
assert_eq!(before.doc_count, 1);
assert!(before.membership_count > 0);
assert!(!before.storage_keys.is_empty());
assert_storage_keys_present(&app, &before.storage_keys).await?;
let job = enqueue_delete_job_with_status(&app, tenant_id, false, Some("active")).await?;
let job_id = job.id;
let handler = DeleteTenantJob::new();
let state = Arc::new(app.state.clone());
let execution = handler.handle(state, job, storage).await;
assert_job_success(&execution);
record_job_outcome(&app, job_id, &execution).await?;
assert_eq!(fetch_job_status(&app, job_id).await?, STATUS_SUCCEEDED);
let after = tenant_snapshot(&app, tenant_id, &storage_prefix).await?;
assert_eq!(after.doc_count, 0);
assert!(after.membership_count > 0);
assert_storage_keys_absent(&app, &before.storage_keys).await?;
assert_eq!(storage_object_count(&app, &storage_prefix).await?, 0);
assert_eq!(
fetch_tenant_status(&app, tenant_id).await?,
TenantStatus::Active
);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn delete_tenant_job_removes_tenant_entirely() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "delete-remove";
app.insert_user("tenant-remove", TestUserRole::Owner)
.await?;
let token = app.login_token("tenant-remove", password).await?;
upload_fixture(&app, &token, "remove-1.pdf", b"remove-1").await?;
upload_fixture(&app, &token, "remove-2.pdf", b"remove-2").await?;
let tenant_id = default_tenant_id(&app)?;
set_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?;
assert_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?;
let storage = tenant_storage(&app, tenant_id)?;
let storage_prefix = storage.root_prefix().to_string();
let before = tenant_snapshot(&app, tenant_id, &storage_prefix).await?;
assert!(before.doc_count >= 2);
assert!(before.membership_count > 0);
assert!(before.storage_keys.len() >= 2);
assert_storage_keys_present(&app, &before.storage_keys).await?;
let job = enqueue_delete_job(&app, tenant_id, true).await?;
let job_id = job.id;
let handler = DeleteTenantJob::new();
let state = Arc::new(app.state.clone());
let execution = handler.handle(state, job, storage).await;
assert_job_success(&execution);
record_job_outcome(&app, job_id, &execution).await?;
assert_eq!(fetch_job_status(&app, job_id).await?, STATUS_SUCCEEDED);
let after = tenant_snapshot(&app, tenant_id, &storage_prefix).await?;
assert_eq!(after.doc_count, 0);
assert_eq!(after.membership_count, 0);
assert!(after.storage_keys.is_empty());
assert_storage_keys_absent(&app, &before.storage_keys).await?;
assert_eq!(storage_object_count(&app, &storage_prefix).await?, 0);
assert!(!tenant_exists(&app, tenant_id).await?);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn delete_tenant_job_rejects_invalid_signature() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "delete-invalid";
app.insert_user("tenant-invalid", TestUserRole::Owner)
.await?;
let token = app.login_token("tenant-invalid", password).await?;
upload_fixture(&app, &token, "invalid.pdf", b"invalid").await?;
let tenant_id = default_tenant_id(&app)?;
set_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?;
assert_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?;
let storage = tenant_storage(&app, tenant_id)?;
let storage_prefix = storage.root_prefix().to_string();
let before = tenant_snapshot(&app, tenant_id, &storage_prefix).await?;
assert_eq!(before.doc_count, 1);
assert!(before.membership_count > 0);
assert!(!before.storage_keys.is_empty());
assert_storage_keys_present(&app, &before.storage_keys).await?;
let job = enqueue_delete_job_with_invalid_signature(&app, tenant_id, false).await?;
let job_id = job.id;
let handler = DeleteTenantJob::new();
let state = Arc::new(app.state.clone());
let execution = handler.handle(state, job, storage).await;
match execution {
JobExecution::Failed { ref error } => {
assert!(error.contains("signature"), "unexpected error: {error}");
}
_ => bail!("delete job should fail when signature is invalid"),
}
record_job_outcome(&app, job_id, &execution).await?;
assert_eq!(fetch_job_status(&app, job_id).await?, STATUS_FAILED);
let after = tenant_snapshot(&app, tenant_id, &storage_prefix).await?;
assert_eq!(after.doc_count, before.doc_count);
assert_eq!(after.membership_count, before.membership_count);
assert_storage_keys_present(&app, &before.storage_keys).await?;
assert_eq!(
storage_object_count(&app, &storage_prefix).await?,
before.storage_keys.len()
);
assert_eq!(
fetch_tenant_status(&app, tenant_id).await?,
TenantStatus::Deleting
);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn delete_tenant_job_rejects_invalid_final_status() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "delete-invalid-status";
app.insert_user("tenant-invalid-status", TestUserRole::Owner)
.await?;
let token = app.login_token("tenant-invalid-status", password).await?;
upload_fixture(&app, &token, "invalid-status.pdf", b"payload").await?;
let tenant_id = default_tenant_id(&app)?;
set_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?;
assert_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?;
let storage = tenant_storage(&app, tenant_id)?;
let storage_prefix = storage.root_prefix().to_string();
let before = tenant_snapshot(&app, tenant_id, &storage_prefix).await?;
assert_eq!(before.doc_count, 1);
assert!(before.membership_count > 0);
let job = enqueue_delete_job_with_overrides(
&app,
tenant_id,
false,
None,
PayloadOverrides {
final_status: Some("weird"),
..PayloadOverrides::default()
},
)
.await?;
let job_id = job.id;
let handler = DeleteTenantJob::new();
let state = Arc::new(app.state.clone());
let execution = handler.handle(state, job, storage).await;
match execution {
JobExecution::Failed { ref error } => {
assert!(error.contains("payload"), "unexpected error: {error}");
}
_ => bail!("delete job should fail when final_status is invalid"),
}
record_job_outcome(&app, job_id, &execution).await?;
assert_eq!(fetch_job_status(&app, job_id).await?, STATUS_FAILED);
let after = tenant_snapshot(&app, tenant_id, &storage_prefix).await?;
assert_eq!(after.doc_count, before.doc_count);
assert_eq!(after.membership_count, before.membership_count);
assert_storage_keys_present(&app, &before.storage_keys).await?;
assert_eq!(
fetch_tenant_status(&app, tenant_id).await?,
TenantStatus::Deleting
);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn delete_tenant_job_rejects_malformed_issued_at() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "delete-bad-issued-at";
app.insert_user("tenant-issued", TestUserRole::Owner)
.await?;
let token = app.login_token("tenant-issued", password).await?;
upload_fixture(&app, &token, "issued.pdf", b"issued").await?;
let tenant_id = default_tenant_id(&app)?;
set_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?;
assert_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?;
let storage = tenant_storage(&app, tenant_id)?;
let job = enqueue_delete_job_with_overrides(
&app,
tenant_id,
false,
None,
PayloadOverrides {
issued_at: Some("definitely-not-time"),
..PayloadOverrides::default()
},
)
.await?;
let job_id = job.id;
let handler = DeleteTenantJob::new();
let state = Arc::new(app.state.clone());
let execution = handler.handle(state, job, storage).await;
match execution {
JobExecution::Failed { ref error } => {
assert!(error.contains("issued_at"), "unexpected error: {error}");
}
_ => bail!("delete job should fail when issued_at is malformed"),
}
record_job_outcome(&app, job_id, &execution).await?;
assert_eq!(fetch_job_status(&app, job_id).await?, STATUS_FAILED);
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn delete_tenant_job_rejects_stale_confirmation() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let password = "delete-stale";
app.insert_user("tenant-stale", TestUserRole::Owner).await?;
let token = app.login_token("tenant-stale", password).await?;
upload_fixture(&app, &token, "stale.pdf", b"stale").await?;
let tenant_id = default_tenant_id(&app)?;
set_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?;
assert_tenant_status(&app, tenant_id, TenantStatus::Deleting).await?;
let storage = tenant_storage(&app, tenant_id)?;
let stale_time = (Utc::now() - ChronoDuration::minutes(10)).to_rfc3339();
let job = enqueue_delete_job_with_overrides(
&app,
tenant_id,
false,
None,
PayloadOverrides {
issued_at: Some(&stale_time),
..PayloadOverrides::default()
},
)
.await?;
let job_id = job.id;
let handler = DeleteTenantJob::new();
let state = Arc::new(app.state.clone());
let execution = handler.handle(state, job, storage).await;
match execution {
JobExecution::Failed { ref error } => {
assert!(error.contains("expired"), "unexpected error: {error}");
}
_ => bail!("delete job should fail when confirmation is stale"),
}
record_job_outcome(&app, job_id, &execution).await?;
assert_eq!(fetch_job_status(&app, job_id).await?, STATUS_FAILED);
app.cleanup().await?;
Ok(())
}
async fn upload_fixture(app: &TestApp, token: &str, filename: &str, contents: &[u8]) -> Result<()> {
let response = app
.upload_document(
"/api/documents",
filename,
"application/pdf",
contents,
None,
token,
)
.await?;
assert_eq!(response.status(), StatusCode::CREATED);
body_to_vec(response.into_body()).await?;
Ok(())
}
fn default_tenant_id(app: &TestApp) -> Result<Uuid> {
Ok(app
.state
.tenants
.get_by_name("test_tenant")
.map_err(|err| anyhow!("tenant lookup failed: {err:?}"))?
.id)
}
async fn set_tenant_status(app: &TestApp, tenant_id: Uuid, status: TenantStatus) -> Result<()> {
app.with_conn(move |conn| {
diesel::update(tenants::table.find(tenant_id))
.set(tenants::status.eq(status))
.execute(conn)?;
Ok(())
})
.await
}
async fn assert_tenant_status(
app: &TestApp,
tenant_id: Uuid,
expected: TenantStatus,
) -> Result<()> {
let status = fetch_tenant_status(app, tenant_id).await?;
assert_eq!(status, expected);
Ok(())
}
async fn tenant_snapshot(
app: &TestApp,
tenant_id: Uuid,
storage_prefix: &str,
) -> Result<TenantSnapshot> {
let (doc_count, membership_count) = app
.with_conn(move |conn| {
let doc_count: i64 = documents::table
.filter(documents::tenant_id.eq(tenant_id))
.select(count_star())
.get_result(conn)?;
let membership_count: i64 = user_memberships::table
.filter(user_memberships::tenant_id.eq(tenant_id))
.select(count_star())
.get_result(conn)?;
Ok::<_, anyhow::Error>((doc_count, membership_count))
})
.await?;
let storage_keys = app.storage().keys_with_prefix(storage_prefix).await;
Ok(TenantSnapshot {
doc_count,
membership_count,
storage_keys,
})
}
async fn tenant_exists(app: &TestApp, tenant_id: Uuid) -> Result<bool> {
app.with_conn(move |conn| {
let exists_value: bool =
select(exists(tenants::table.filter(tenants::id.eq(tenant_id)))).get_result(conn)?;
Ok(exists_value)
})
.await
}
async fn fetch_tenant_status(app: &TestApp, tenant_id: Uuid) -> Result<TenantStatus> {
app.with_conn(move |conn| {
tenants::table
.find(tenant_id)
.select(tenants::status)
.first(conn)
.map_err(Into::into)
})
.await
}
async fn record_job_outcome(app: &TestApp, job_id: Uuid, execution: &JobExecution) -> Result<()> {
match execution {
JobExecution::Success => {
app.with_conn(move |conn| {
mark_job_succeeded(conn, job_id)
.map_err(|err| anyhow!("mark succeeded failed: {err}"))
})
.await?
}
JobExecution::Failed { error } => {
let error = error.clone();
app.with_conn(move |conn| {
mark_job_failed(conn, job_id, &error)
.map_err(|err| anyhow!("mark failed failed: {err}"))
})
.await?
}
JobExecution::Retry { .. } => bail!("retry outcome not expected in tenant deletion tests"),
}
Ok(())
}
async fn fetch_job_status(app: &TestApp, job_id: Uuid) -> Result<String> {
app.with_conn(move |conn| {
jobs::table
.find(job_id)
.select(jobs::status)
.first::<String>(conn)
.map_err(Into::into)
})
.await
}
fn tenant_storage(app: &TestApp, tenant_id: Uuid) -> Result<papercrate::storage::TenantStorage> {
app.state
.storage_for_tenant(tenant_id)
.map_err(|err| anyhow!("storage unavailable: {err:?}"))
}
async fn storage_object_count(app: &TestApp, prefix: &str) -> Result<usize> {
Ok(app.storage().object_count_with_prefix(prefix).await)
}
async fn assert_storage_keys_present(app: &TestApp, keys: &[String]) -> Result<()> {
let storage = app.storage();
for key in keys {
assert!(
storage.contains_key(key).await,
"expected storage object '{}' to exist",
key
);
}
Ok(())
}
async fn assert_storage_keys_absent(app: &TestApp, keys: &[String]) -> Result<()> {
let storage = app.storage();
for key in keys {
assert!(
!storage.contains_key(key).await,
"expected storage object '{}' to be deleted",
key
);
}
Ok(())
}
async fn enqueue_delete_job(
app: &TestApp,
tenant_id: Uuid,
remove_tenant: bool,
) -> Result<papercrate::models::Job> {
enqueue_delete_job_with_status(app, tenant_id, remove_tenant, None).await
}
async fn enqueue_delete_job_with_status(
app: &TestApp,
tenant_id: Uuid,
remove_tenant: bool,
final_status: Option<&'static str>,
) -> Result<papercrate::models::Job> {
enqueue_delete_job_with_overrides(
app,
tenant_id,
remove_tenant,
final_status,
PayloadOverrides::default(),
)
.await
}
async fn enqueue_delete_job_with_invalid_signature(
app: &TestApp,
tenant_id: Uuid,
remove_tenant: bool,
) -> Result<papercrate::models::Job> {
let mut job = enqueue_delete_job_with_overrides(
app,
tenant_id,
remove_tenant,
None,
PayloadOverrides::default(),
)
.await?;
let job_id = job.id;
let mut payload = job.payload.clone();
payload["signature"] = json!("deadbeefdeadbeefdeadbeefdeadbeef");
let payload_for_db = payload.clone();
app.with_conn(move |conn| {
diesel::update(jobs::table.find(job_id))
.set(jobs::payload.eq(payload_for_db))
.execute(conn)?;
Ok(())
})
.await?;
job.payload = payload;
Ok(job)
}
async fn enqueue_delete_job_with_overrides(
app: &TestApp,
tenant_id: Uuid,
remove_tenant: bool,
final_status: Option<&str>,
overrides: PayloadOverrides<'_>,
) -> Result<papercrate::models::Job> {
let secret = app.state.config.jwt_secret.clone();
let overrides_owned = PayloadOverridesOwned::from(overrides);
let requested_final_status = final_status.map(|value| value.to_string());
app.with_conn(move |conn| {
let tenant_name: String = tenants::table
.find(tenant_id)
.select(tenants::name)
.first(conn)
.map_err(|err| anyhow!("tenant lookup failed: {err}"))?;
let payload = build_signed_delete_payload(
tenant_id,
&tenant_name,
remove_tenant,
requested_final_status.as_deref(),
&secret,
overrides_owned.as_borrowed(),
)?;
enqueue_job(conn, tenant_id, JOB_DELETE_TENANT, payload, None)
.map_err(|err| anyhow!("enqueue failed: {err}"))
})
.await
}
fn build_signed_delete_payload(
tenant_id: Uuid,
tenant_name: &str,
remove_tenant: bool,
requested_final_status: Option<&str>,
secret: &str,
overrides: PayloadOverrides<'_>,
) -> Result<Value> {
let action = if remove_tenant {
DeleteAction::Delete
} else {
DeleteAction::Reset
};
let nonce = overrides
.nonce
.map(|value| value.to_string())
.unwrap_or_else(|| format!("test-delete-nonce-{}", Uuid::new_v4()));
let issued_at = overrides
.issued_at
.map(|value| value.to_string())
.unwrap_or_else(|| Utc::now().to_rfc3339());
let payload_final_status = if remove_tenant {
None
} else if let Some(value) = overrides.final_status {
Some(value.to_string())
} else {
Some(requested_final_status.unwrap_or("suspended").to_string())
};
let message = build_delete_proof_message(
tenant_id,
tenant_name,
action,
&nonce,
&issued_at,
payload_final_status.as_deref(),
);
let signature = sign_delete_proof(secret, &message)
.map_err(|err| anyhow!("failed to sign delete proof: {err}"))?;
let mut payload = json!({
"remove_tenant": remove_tenant,
"tenant_name": tenant_name,
"action": action.as_str(),
"nonce": nonce,
"issued_at": issued_at,
"signature": signature,
});
if let Some(status) = payload_final_status {
payload["final_status"] = json!(status);
}
Ok(payload)
}
struct TenantSnapshot {
doc_count: i64,
membership_count: i64,
storage_keys: Vec<String>,
}
#[derive(Default)]
struct PayloadOverrides<'a> {
final_status: Option<&'a str>,
issued_at: Option<&'a str>,
nonce: Option<&'a str>,
}
fn assert_job_success(execution: &JobExecution) {
match execution {
JobExecution::Success => {}
JobExecution::Failed { error } => panic!("delete job failed unexpectedly: {error}"),
JobExecution::Retry { error, .. } => {
panic!("delete job asked for retry unexpectedly: {error}")
}
}
}
#[derive(Default, Clone)]
struct PayloadOverridesOwned {
final_status: Option<String>,
issued_at: Option<String>,
nonce: Option<String>,
}
impl<'a> From<PayloadOverrides<'a>> for PayloadOverridesOwned {
fn from(value: PayloadOverrides<'a>) -> Self {
Self {
final_status: value.final_status.map(|s| s.to_string()),
issued_at: value.issued_at.map(|s| s.to_string()),
nonce: value.nonce.map(|s| s.to_string()),
}
}
}
impl PayloadOverridesOwned {
fn as_borrowed(&self) -> PayloadOverrides<'_> {
PayloadOverrides {
final_status: self.final_status.as_deref(),
issued_at: self.issued_at.as_deref(),
nonce: self.nonce.as_deref(),
}
}
}
+51
View File
@@ -0,0 +1,51 @@
use anyhow::Result;
use axum::http::StatusCode;
use chrono::Utc;
use diesel::prelude::*;
use papercrate::models::TenantStatus;
use papercrate::schema::tenants::dsl as tenants_dsl;
use papercrate::test_support::{acquire_db_lock, TestApp, TestUserRole};
use serde_json::json;
use uuid::Uuid;
#[tokio::test]
async fn tenant_management_is_scoped_to_memberships() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let username = "tenant-owner";
app.insert_user(username, TestUserRole::Owner).await?;
let token = app.login_token(username, "irrelevant").await?;
let other_tenant_id = app
.with_conn(|conn| {
let other_id = Uuid::new_v4();
let now = Utc::now().naive_utc();
diesel::insert_into(tenants_dsl::tenants)
.values((
tenants_dsl::id.eq(other_id),
tenants_dsl::name.eq(format!("foreign-{other_id}")),
tenants_dsl::storage_root.eq(Some(format!("test-tenants/{other_id}/"))),
tenants_dsl::quickwit_index.eq(None::<String>),
tenants_dsl::config.eq(json!({})),
tenants_dsl::created_at.eq(now),
tenants_dsl::updated_at.eq(now),
tenants_dsl::status.eq(TenantStatus::Active),
tenants_dsl::created_by.eq(None::<Uuid>),
))
.execute(conn)?;
Ok(other_id)
})
.await?;
let response = app
.patch_json(
&format!("/api/tenants/{other_tenant_id}"),
&json!({ "name": "should-not-work" }),
Some(&token),
)
.await?;
assert_eq!(response.status(), StatusCode::FORBIDDEN);
Ok(())
}