Initial commit
This commit is contained in:
@@ -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, ®enerated.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)?)
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
pub use papercrate::test_support::*;
|
||||
@@ -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)?)
|
||||
}
|
||||
@@ -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
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
Reference in New Issue
Block a user