update rust deps

This commit is contained in:
2025-11-10 10:51:57 +01:00
parent 737c44da12
commit 578dfd8998
22 changed files with 1672 additions and 1473 deletions
+575 -357
View File
File diff suppressed because it is too large Load Diff
+14 -14
View File
@@ -5,14 +5,14 @@ edition = "2021"
[dependencies] [dependencies]
# Web framework # Web framework
axum = { version = "0.7", features = ["multipart"] } axum = { version = "0.8", features = ["multipart"] }
tokio = { version = "1", features = ["full"] } tokio = { version = "1.48", features = ["full"] }
tower = { version = "0.4", features = ["make", "util"] } tower = { version = "0.5", features = ["make", "util"] }
tower-http = { version = "0.6", features = ["cors", "trace"] } tower-http = { version = "0.6", features = ["cors", "trace"] }
axum-extra = { version = "0.9", features = ["typed-header"] } axum-extra = { version = "0.12", features = ["typed-header"] }
# Database # Database
diesel = { version = "2.1", features = ["postgres", "uuid", "chrono", "serde_json", "r2d2"] } diesel = { version = "2.3.3", features = ["postgres", "uuid", "chrono", "serde_json", "r2d2"] }
diesel_migrations = "2.1" diesel_migrations = "2.1"
uuid = { version = "1.6", features = ["v4", "serde"] } uuid = { version = "1.6", features = ["v4", "serde"] }
chrono = { version = "0.4", features = ["serde"] } chrono = { version = "0.4", features = ["serde"] }
@@ -35,13 +35,13 @@ hex = "0.4"
bytes = "1.5" bytes = "1.5"
async-trait = "0.1" async-trait = "0.1"
image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] } image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] }
pdfium-render = "0.8" pdfium-render = "0.8.36"
mime_guess = "2.0" mime_guess = "2.0"
tempfile = "3.10" tempfile = "3.10"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] } reqwest = { version = "0.12.24", default-features = false, features = ["json", "rustls-tls", "stream"] }
percent-encoding = "2.3" percent-encoding = "2.3"
base64 = "0.21" base64 = "0.22"
quick-xml = "0.32" quick-xml = "0.38"
futures-util = "0.3" futures-util = "0.3"
url = "2.5" url = "2.5"
once_cell = "1.19" once_cell = "1.19"
@@ -49,23 +49,23 @@ utoipa = { version = "4.2", default-features = false, features = ["chrono", "uui
clap = { version = "4.5", features = ["derive"] } clap = { version = "4.5", features = ["derive"] }
# Error handling # Error handling
thiserror = "1.0" thiserror = "2.0"
anyhow = "1.0" anyhow = "1.0"
# Authentication & security # Authentication & security
argon2 = "0.5" argon2 = "0.5"
jsonwebtoken = "9" jsonwebtoken = { version = "10", features = ["rust_crypto"] }
webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-credential-internals"] } webauthn-rs = { version = "0.5", features = ["danger-allow-state-serialisation", "danger-credential-internals"] }
serde_bytes = "0.11" serde_bytes = "0.11"
serde_cbor_2 = "0.13" serde_cbor_2 = "0.13"
# Misc # Misc
rand = "0.8" rand = "0.9"
hyper = "1.2"
http-body-util = "0.1"
[dev-dependencies] [dev-dependencies]
once_cell = "1.19" once_cell = "1.19"
hyper = "1.2"
http-body-util = "0.1"
webauthn-rs-core = "0.5" webauthn-rs-core = "0.5"
[[bin]] [[bin]]
+4 -4
View File
@@ -1,11 +1,10 @@
use argon2::{ use argon2::{
password_hash::{PasswordHasher, SaltString}, password_hash::{rand_core::OsRng as PasswordHashOsRng, PasswordHasher, SaltString},
Argon2, Argon2,
}; };
use chrono::{NaiveDateTime, Utc}; use chrono::{NaiveDateTime, Utc};
use diesel::prelude::*; use diesel::prelude::*;
use rand::rngs::OsRng; use rand::{rngs::OsRng, TryRngCore};
use rand::RngCore;
use uuid::Uuid; use uuid::Uuid;
use crate::{ use crate::{
@@ -264,7 +263,8 @@ fn generate_secret() -> Result<String, AppError> {
} }
fn hash_secret(secret: &str) -> Result<String, AppError> { fn hash_secret(secret: &str) -> Result<String, AppError> {
let salt = SaltString::generate(&mut OsRng); let mut salt_rng = PasswordHashOsRng;
let salt = SaltString::generate(&mut salt_rng);
let hash = Argon2::default() let hash = Argon2::default()
.hash_password(secret.as_bytes(), &salt) .hash_password(secret.as_bytes(), &salt)
.map_err(|err| { .map_err(|err| {
+2 -1
View File
@@ -2,11 +2,12 @@ use anyhow::Result;
use chrono::{Duration, Utc}; use chrono::{Duration, Utc};
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation}; use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use utoipa::ToSchema;
use uuid::Uuid; use uuid::Uuid;
use crate::config::AppConfig; use crate::config::AppConfig;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, ToSchema)]
#[serde(rename_all = "snake_case")] #[serde(rename_all = "snake_case")]
pub enum PrincipalKind { pub enum PrincipalKind {
UserSession, UserSession,
+17 -11
View File
@@ -7,7 +7,7 @@ pub mod password;
use std::sync::{Arc, Mutex}; use std::sync::{Arc, Mutex};
use axum::{async_trait, extract::FromRequestParts, http::request::Parts}; use axum::{extract::FromRequestParts, http::request::Parts};
use axum_extra::headers::{authorization::Bearer, Authorization}; use axum_extra::headers::{authorization::Bearer, Authorization};
use axum_extra::TypedHeader; use axum_extra::TypedHeader;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -52,20 +52,22 @@ pub struct AuthenticatedUser {
pub capabilities: Vec<ApiCapability>, pub capabilities: Vec<ApiCapability>,
} }
#[async_trait]
impl FromRequestParts<AppState> for AuthenticatedUser { impl FromRequestParts<AppState> for AuthenticatedUser {
type Rejection = AppError; type Rejection = AppError;
async fn from_request_parts( #[allow(refining_impl_trait)]
parts: &mut Parts, fn from_request_parts<'a>(
parts: &'a mut Parts,
state: &AppState, state: &AppState,
) -> Result<Self, Self::Rejection> { ) -> impl std::future::Future<Output = Result<Self, Self::Rejection>> + Send + 'a {
let state = state.clone();
async move {
if let Some(user) = parts.extensions.get::<AuthenticatedUser>() { if let Some(user) = parts.extensions.get::<AuthenticatedUser>() {
return Ok(user.clone()); return Ok(user.clone());
} }
let TypedHeader(Authorization(bearer)) = let TypedHeader(Authorization(bearer)) =
TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, state) TypedHeader::<Authorization<Bearer>>::from_request_parts(parts, &state)
.await .await
.map_err(|_| AppError::unauthorized())?; .map_err(|_| AppError::unauthorized())?;
@@ -104,6 +106,7 @@ impl FromRequestParts<AppState> for AuthenticatedUser {
Ok(user) Ok(user)
} }
} }
}
pub struct TenantScopedConn { pub struct TenantScopedConn {
pub conn: PgPooledConnection, pub conn: PgPooledConnection,
@@ -118,15 +121,17 @@ impl TenantScopedConn {
} }
} }
#[async_trait]
impl FromRequestParts<AppState> for TenantScopedConn { impl FromRequestParts<AppState> for TenantScopedConn {
type Rejection = AppError; type Rejection = AppError;
async fn from_request_parts( #[allow(refining_impl_trait)]
parts: &mut Parts, fn from_request_parts<'a>(
parts: &'a mut Parts,
state: &AppState, state: &AppState,
) -> Result<Self, Self::Rejection> { ) -> impl std::future::Future<Output = Result<Self, Self::Rejection>> + Send + 'a {
let user = AuthenticatedUser::from_request_parts(parts, state).await?; let state = state.clone();
async move {
let user = AuthenticatedUser::from_request_parts(parts, &state).await?;
let tenant_id = user.tenant_id; let tenant_id = user.tenant_id;
let conn = if let Some(holder) = parts.extensions.remove::<TenantConnectionHolder>() { let conn = if let Some(holder) = parts.extensions.remove::<TenantConnectionHolder>() {
holder holder
@@ -144,3 +149,4 @@ impl FromRequestParts<AppState> for TenantScopedConn {
}) })
} }
} }
}
+6 -3
View File
@@ -1,9 +1,11 @@
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use argon2::{ use argon2::{
password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, password_hash::{
rand_core::OsRng as PasswordHashOsRng, PasswordHash, PasswordHasher, PasswordVerifier,
SaltString,
},
Argon2, Argon2,
}; };
use rand::rngs::OsRng;
pub fn verify_password(password: &str, password_hash: &str) -> Result<bool> { pub fn verify_password(password: &str, password_hash: &str) -> Result<bool> {
let parsed_hash = PasswordHash::new(password_hash).map_err(|err| anyhow!(err))?; let parsed_hash = PasswordHash::new(password_hash).map_err(|err| anyhow!(err))?;
@@ -13,7 +15,8 @@ pub fn verify_password(password: &str, password_hash: &str) -> Result<bool> {
} }
pub fn hash_password(password: &str) -> Result<String> { pub fn hash_password(password: &str) -> Result<String> {
let salt = SaltString::generate(&mut OsRng); let mut rng = PasswordHashOsRng;
let salt = SaltString::generate(&mut rng);
let hash = Argon2::default() let hash = Argon2::default()
.hash_password(password.as_bytes(), &salt) .hash_password(password.as_bytes(), &salt)
.map_err(|err| anyhow!(err))?; .map_err(|err| anyhow!(err))?;
+4 -2
View File
@@ -4,7 +4,7 @@ use anyhow::{anyhow, bail, Context, Result};
use chrono::{Duration as ChronoDuration, Utc}; use chrono::{Duration as ChronoDuration, Utc};
use clap::{Parser, Subcommand, ValueEnum}; use clap::{Parser, Subcommand, ValueEnum};
use diesel::{dsl::exists, prelude::*, select}; use diesel::{dsl::exists, prelude::*, select};
use rand::{rngs::OsRng, RngCore}; use rand::{rngs::OsRng, TryRngCore};
use reqwest::{Client, Method, StatusCode}; use reqwest::{Client, Method, StatusCode};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use uuid::Uuid; use uuid::Uuid;
@@ -323,7 +323,9 @@ fn create_magic_token(
fn generate_random_token() -> String { fn generate_random_token() -> String {
let mut bytes = [0u8; 32]; let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes); OsRng
.try_fill_bytes(&mut bytes)
.expect("failed to read random bytes");
hex::encode(bytes) hex::encode(bytes)
} }
+1
View File
@@ -17,3 +17,4 @@ pub mod tenants;
pub mod utils; pub mod utils;
pub mod workers; pub mod workers;
pub use workers::{default_handlers, Worker}; pub use workers::{default_handlers, Worker};
pub mod test_support;
+1 -1
View File
@@ -785,7 +785,7 @@ pub async fn delete_document(
patch, patch,
path = "/api/documents/{id}", path = "/api/documents/{id}",
params(("id" = Uuid, Path, description = "Document ID")), params(("id" = Uuid, Path, description = "Document ID")),
request_body = UpdateDocumentRequest, request_body = crate::services::documents::UpdateDocumentRequest,
responses((status = 200, description = "Updated document", body = DocumentDetailResponse)), responses((status = 200, description = "Updated document", body = DocumentDetailResponse)),
tag = "Documents" tag = "Documents"
)] )]
+30 -30
View File
@@ -125,92 +125,92 @@ pub fn create_router(state: AppState) -> Router<()> {
])), ])),
) )
.route( .route(
"/:id", "/{id}",
get(documents::get_document).layer(RequireCapabilitiesLayer::all([ get(documents::get_document).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsRead, ApiCapability::DocumentsRead,
])), ])),
) )
.route( .route(
"/:id/trash", "/{id}/trash",
post(documents::trash_document).layer(RequireCapabilitiesLayer::all([ post(documents::trash_document).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsWrite, ApiCapability::DocumentsWrite,
])), ])),
) )
.route( .route(
"/:id", "/{id}",
delete(documents::delete_document).layer(RequireCapabilitiesLayer::all([ delete(documents::delete_document).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsWrite, ApiCapability::DocumentsWrite,
])), ])),
) )
.route( .route(
"/:id", "/{id}",
patch(documents::update_document).layer(RequireCapabilitiesLayer::all([ patch(documents::update_document).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsEdit, ApiCapability::DocumentsEdit,
])), ])),
) )
.route( .route(
"/:id/assets", "/{id}/assets",
get(documents::list_document_assets).layer(RequireCapabilitiesLayer::all([ get(documents::list_document_assets).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsRead, ApiCapability::DocumentsRead,
])), ])),
) )
.route( .route(
"/:id/assets", "/{id}/assets",
post(documents::request_document_assets).layer(RequireCapabilitiesLayer::all([ post(documents::request_document_assets).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsWrite, ApiCapability::DocumentsWrite,
])), ])),
) )
.route( .route(
"/:id/folder", "/{id}/folder",
patch(documents::move_document).layer(RequireCapabilitiesLayer::all([ patch(documents::move_document).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsEdit, ApiCapability::DocumentsEdit,
])), ])),
) )
.route( .route(
"/:id/versions", "/{id}/versions",
get(documents::list_document_versions).layer(RequireCapabilitiesLayer::all([ get(documents::list_document_versions).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsRead, ApiCapability::DocumentsRead,
])), ])),
) )
.route( .route(
"/:id/versions/:version_id", "/{id}/versions/{version_id}",
get(documents::get_document_version).layer(RequireCapabilitiesLayer::all([ get(documents::get_document_version).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsRead, ApiCapability::DocumentsRead,
])), ])),
) )
.route( .route(
"/:id/restore", "/{id}/restore",
post(documents::restore_document).layer(RequireCapabilitiesLayer::all([ post(documents::restore_document).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsEdit, ApiCapability::DocumentsEdit,
])), ])),
) )
.route( .route(
"/:id/tags", "/{id}/tags",
post(documents::assign_tags).layer(RequireCapabilitiesLayer::all([ post(documents::assign_tags).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsEdit, ApiCapability::DocumentsEdit,
])), ])),
) )
.route( .route(
"/:id/tags/:tag_id", "/{id}/tags/{tag_id}",
delete(documents::remove_tag).layer(RequireCapabilitiesLayer::all([ delete(documents::remove_tag).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsEdit, ApiCapability::DocumentsEdit,
])), ])),
) )
.route( .route(
"/:id/correspondents", "/{id}/correspondents",
post(documents::assign_correspondents).layer(RequireCapabilitiesLayer::all([ post(documents::assign_correspondents).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsEdit, ApiCapability::DocumentsEdit,
])), ])),
) )
.route( .route(
"/:id/correspondents/:correspondent_id", "/{id}/correspondents/{correspondent_id}",
delete(documents::remove_correspondent).layer(RequireCapabilitiesLayer::all([ delete(documents::remove_correspondent).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsEdit, ApiCapability::DocumentsEdit,
])), ])),
); );
let download_routes = let download_routes =
Router::new().route("/download/:token", get(documents::download_with_token)); Router::new().route("/download/{token}", get(documents::download_with_token));
let folders_routes = Router::new() let folders_routes = Router::new()
.route( .route(
@@ -229,22 +229,22 @@ pub fn create_router(state: AppState) -> Router<()> {
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersRead])), .layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersRead])),
) )
.route( .route(
"/:id", "/{id}",
get(folders::get_folder) get(folders::get_folder)
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersRead])), .layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersRead])),
) )
.route( .route(
"/:id", "/{id}",
delete(folders::delete_folder) delete(folders::delete_folder)
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersWrite])), .layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersWrite])),
) )
.route( .route(
"/:id", "/{id}",
patch(folders::update_folder) patch(folders::update_folder)
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersEdit])), .layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersEdit])),
) )
.route( .route(
"/:id/contents", "/{id}/contents",
get(folders::list_folder_contents) get(folders::list_folder_contents)
.layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersRead])), .layer(RequireCapabilitiesLayer::all([ApiCapability::FoldersRead])),
); );
@@ -259,11 +259,11 @@ pub fn create_router(state: AppState) -> Router<()> {
post(tags::create_tag).layer(RequireCapabilitiesLayer::all([ApiCapability::TagsWrite])), post(tags::create_tag).layer(RequireCapabilitiesLayer::all([ApiCapability::TagsWrite])),
) )
.route( .route(
"/:id", "/{id}",
patch(tags::update_tag).layer(RequireCapabilitiesLayer::all([ApiCapability::TagsEdit])), patch(tags::update_tag).layer(RequireCapabilitiesLayer::all([ApiCapability::TagsEdit])),
) )
.route( .route(
"/:id", "/{id}",
delete(tags::delete_tag) delete(tags::delete_tag)
.layer(RequireCapabilitiesLayer::all([ApiCapability::TagsWrite])), .layer(RequireCapabilitiesLayer::all([ApiCapability::TagsWrite])),
); );
@@ -282,13 +282,13 @@ pub fn create_router(state: AppState) -> Router<()> {
])), ])),
) )
.route( .route(
"/:id", "/{id}",
patch(correspondents::update_correspondent).layer(RequireCapabilitiesLayer::all([ patch(correspondents::update_correspondent).layer(RequireCapabilitiesLayer::all([
ApiCapability::CorrespondentsEdit, ApiCapability::CorrespondentsEdit,
])), ])),
) )
.route( .route(
"/:id", "/{id}",
delete(correspondents::delete_correspondent).layer(RequireCapabilitiesLayer::all([ delete(correspondents::delete_correspondent).layer(RequireCapabilitiesLayer::all([
ApiCapability::CorrespondentsWrite, ApiCapability::CorrespondentsWrite,
])), ])),
@@ -306,12 +306,12 @@ pub fn create_router(state: AppState) -> Router<()> {
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])), .layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])),
) )
.route( .route(
"/api-tokens/:id/regenerate", "/api-tokens/{id}/regenerate",
post(profile::regenerate_api_token) post(profile::regenerate_api_token)
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])), .layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])),
) )
.route( .route(
"/api-tokens/:id", "/api-tokens/{id}",
delete(profile::delete_api_token) delete(profile::delete_api_token)
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])), .layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])),
) )
@@ -321,7 +321,7 @@ pub fn create_router(state: AppState) -> Router<()> {
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileRead])), .layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileRead])),
) )
.route( .route(
"/passkeys/:id", "/passkeys/{id}",
delete(profile::delete_passkey) delete(profile::delete_passkey)
.layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])), .layer(RequireCapabilitiesLayer::all([ApiCapability::ProfileWrite])),
); );
@@ -340,19 +340,19 @@ pub fn create_router(state: AppState) -> Router<()> {
])), ])),
) )
.route( .route(
"/:id", "/{id}",
get(capability_sets::get_capability_set).layer(RequireCapabilitiesLayer::all([ get(capability_sets::get_capability_set).layer(RequireCapabilitiesLayer::all([
ApiCapability::CapabilitySetsRead, ApiCapability::CapabilitySetsRead,
])), ])),
) )
.route( .route(
"/:id", "/{id}",
patch(capability_sets::update_capability_set).layer(RequireCapabilitiesLayer::all([ patch(capability_sets::update_capability_set).layer(RequireCapabilitiesLayer::all([
ApiCapability::CapabilitySetsWrite, ApiCapability::CapabilitySetsWrite,
])), ])),
) )
.route( .route(
"/:id", "/{id}",
delete(capability_sets::delete_capability_set).layer(RequireCapabilitiesLayer::all([ delete(capability_sets::delete_capability_set).layer(RequireCapabilitiesLayer::all([
ApiCapability::CapabilitySetsWrite, ApiCapability::CapabilitySetsWrite,
])), ])),
@@ -367,7 +367,7 @@ pub fn create_router(state: AppState) -> Router<()> {
let protected_state = state.clone(); let protected_state = state.clone();
let assets_routes = Router::new().route( let assets_routes = Router::new().route(
"/:asset_id", "/{asset_id}",
get(documents::get_document_asset).layer(RequireCapabilitiesLayer::all([ get(documents::get_document_asset).layer(RequireCapabilitiesLayer::all([
ApiCapability::DocumentsRead, ApiCapability::DocumentsRead,
])), ])),
+4 -2
View File
@@ -2,7 +2,7 @@ use axum::http::{header::SET_COOKIE, HeaderMap, HeaderValue, StatusCode};
use axum::response::{IntoResponse, Response}; use axum::response::{IntoResponse, Response};
use chrono::{Duration as ChronoDuration, TimeZone, Utc}; use chrono::{Duration as ChronoDuration, TimeZone, Utc};
use diesel::{pg::PgConnection, prelude::*, Connection, OptionalExtension}; use diesel::{pg::PgConnection, prelude::*, Connection, OptionalExtension};
use rand::{rngs::OsRng, RngCore}; use rand::{rngs::OsRng, TryRngCore};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
use utoipa::ToSchema; use utoipa::ToSchema;
@@ -781,7 +781,9 @@ fn hash_magic_token(token: &str) -> String {
fn generate_session_token() -> String { fn generate_session_token() -> String {
let mut bytes = [0u8; 32]; let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes); OsRng
.try_fill_bytes(&mut bytes)
.expect("failed to read random bytes");
hex::encode(bytes) hex::encode(bytes)
} }
+891
View File
@@ -0,0 +1,891 @@
use std::collections::HashMap;
use std::env;
use std::sync::Arc;
use std::time::Duration;
use crate::auth::capability_sets::{
ensure_capability_set, owner_capabilities, readonly_capabilities, user_capabilities,
webdav_capabilities,
};
use crate::auth::jwt::{AccessTokenContext, JwtService, PrincipalKind};
use crate::config::AppConfig;
use crate::db::{self, PgPool};
use crate::models::{
Job, NewUser, NewUserMembership, NewUserPasskey, NewUserSession, Tenant, TenantStatus, User,
UserMembership,
};
use crate::routes;
use crate::schema::user_sessions::dsl as session_dsl;
use crate::state::AppState;
use crate::storage::ObjectStorage;
use anyhow::{anyhow, ensure, Context, Result};
use async_trait::async_trait;
use axum::body::Body;
use axum::http::{header, Method, Request};
use axum::Router;
use chrono::{Duration as ChronoDuration, Utc};
use diesel::connection::SimpleConnection;
use diesel::prelude::*;
use diesel::OptionalExtension;
use diesel::PgConnection;
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
use http_body_util::BodyExt;
use once_cell::sync::Lazy;
use rand::{rngs::OsRng, TryRngCore};
use serde::Serialize;
use serde_json::{self, json};
use sha2::{Digest, Sha256};
use tokio::sync::Mutex;
use tower::util::ServiceExt;
use uuid::Uuid;
const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
const RESET_DATABASE_SQL: &str = "DROP SCHEMA IF EXISTS tenant CASCADE;\n\
DROP SCHEMA IF EXISTS shared CASCADE;\n\
DROP SCHEMA IF EXISTS public CASCADE;\n\
CREATE SCHEMA public;\n\
GRANT ALL ON SCHEMA public TO public;";
static DB_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
const TEST_TENANT_NAME: &str = "test_tenant";
#[derive(Clone, Copy, Debug)]
pub enum TestUserRole {
Owner,
Member,
WebDav,
}
#[derive(Clone)]
pub struct StoredObject {
pub key: String,
pub bytes: Vec<u8>,
pub content_type: Option<String>,
pub content_disposition: Option<String>,
}
#[derive(Default)]
pub struct FakeStorage {
objects: Mutex<HashMap<String, StoredObject>>,
}
#[async_trait]
impl ObjectStorage for FakeStorage {
async fn put_object(
&self,
key: &str,
bytes: Vec<u8>,
content_type: Option<String>,
content_disposition: Option<String>,
) -> Result<()> {
let stored = StoredObject {
key: key.to_string(),
bytes,
content_type,
content_disposition,
};
let mut guard = self.objects.lock().await;
guard.insert(stored.key.clone(), stored);
Ok(())
}
async fn presign_get_object(
&self,
key: &str,
expires_in: Duration,
_response_content_disposition: Option<&str>,
) -> Result<String> {
let guard = self.objects.lock().await;
ensure!(guard.contains_key(key), "object {key} missing");
Ok(format!(
"https://fake-storage/{key}?expires_in={}",
expires_in.as_secs()
))
}
async fn get_object(&self, key: &str) -> Result<Vec<u8>> {
let guard = self.objects.lock().await;
guard
.get(key)
.map(|obj| obj.bytes.clone())
.ok_or_else(|| anyhow!("object {key} missing"))
}
async fn delete_object(&self, key: &str) -> Result<()> {
let mut guard = self.objects.lock().await;
guard.remove(key);
Ok(())
}
}
impl FakeStorage {
pub async fn get(&self, key: &str) -> Option<StoredObject> {
let guard = self.objects.lock().await;
guard.get(key).cloned()
}
pub async fn object_count(&self) -> usize {
let guard = self.objects.lock().await;
guard.len()
}
}
pub struct TestApp {
pub state: AppState,
router: Router,
storage: Arc<FakeStorage>,
}
impl TestApp {
pub async fn new() -> Result<Self> {
Self::with_config(|_| {}).await
}
pub async fn with_config<F>(configure: F) -> Result<Self>
where
F: FnOnce(&mut AppConfig),
{
let database_url = env::var("TEST_DATABASE_URL")
.context("TEST_DATABASE_URL must be set for integration tests")?;
let mut config = AppConfig {
database_url: database_url.clone(),
database_max_pool_size: db::DEFAULT_MAX_POOL_SIZE,
server_host: "127.0.0.1".to_string(),
server_port: 0,
webdav_host: "127.0.0.1".to_string(),
webdav_port: 0,
jwt_secret: "test-secret".to_string(),
jwt_issuer: "test-issuer".to_string(),
jwt_audience: "test-audience".to_string(),
jwt_expiry_minutes: 60,
download_token_audience: "test-download".to_string(),
download_token_expiry_minutes: 60,
refresh_token_expiry_days: 30,
refresh_cookie_secure: false,
refresh_cookie_domain: None,
cors_allowed_origin: None,
proxy_downloads: false,
aws_endpoint_url: None,
aws_access_key_id: None,
aws_secret_access_key: None,
aws_region: "us-east-1".to_string(),
s3_bucket: "test-bucket".to_string(),
quickwit_endpoint: None,
quickwit_index: None,
worker_max_document_bytes: 200 * 1024 * 1024,
upload_body_limit_bytes: 128 * 1024 * 1024,
webauthn_rp_id: Some("localhost".to_string()),
webauthn_origin: Some("http://localhost".to_string()),
webauthn_rp_name: "Papercrate".to_string(),
};
configure(&mut config);
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
prepare_database(&pool).await?;
let storage = Arc::new(FakeStorage::default());
let storage_for_state: Arc<dyn ObjectStorage> = storage.clone();
let jwt = JwtService::from_config(&config)?;
let state = AppState::new(pool.clone(), config, storage_for_state, jwt);
let router = routes::create_router(state.clone());
let app = Self {
state,
router,
storage,
};
app.ensure_default_tenant().await?;
Ok(app)
}
pub async fn cleanup(&self) -> Result<()> {
let pool = self.state.pool.clone();
let _ = tokio::task::spawn_blocking(move || -> Result<()> {
let mut conn = pool
.get()
.map_err(|err| anyhow!("failed to get cleanup connection: {err}"))?;
truncate_all(&mut conn)?;
Ok(())
})
.await
.context("cleanup task panicked")?;
self.ensure_default_tenant().await?;
Ok(())
}
pub async fn tenant_id(&self) -> Result<Uuid> {
self.ensure_default_tenant().await
}
pub fn storage(&self) -> Arc<FakeStorage> {
self.storage.clone()
}
pub async fn storage_key_for(&self, key: &str) -> Result<String> {
self.ensure_default_tenant().await?;
let tenant = self
.state
.tenants
.get_by_name(TEST_TENANT_NAME)
.map_err(|err| anyhow!("default tenant not found: {:?}", err))?;
let root = tenant
.storage_root
.clone()
.ok_or_else(|| anyhow!("default tenant missing storage root"))?;
Ok(format!("{}{}", root, key))
}
pub async fn insert_user(&self, username: &str, role: TestUserRole) -> Result<Uuid> {
let username = username.to_string();
let tenant_id = self.ensure_default_tenant().await?;
let user_id = self
.with_conn(move |conn| {
let user = NewUser {
id: Uuid::new_v4(),
username,
};
diesel::insert_into(crate::schema::users::table)
.values(&user)
.execute(conn)
.context("failed to insert user")?;
let capabilities = match role {
TestUserRole::Owner => owner_capabilities(),
TestUserRole::Member => user_capabilities(),
TestUserRole::WebDav => webdav_capabilities(),
};
let capability_set = ensure_capability_set(conn, tenant_id, capabilities)
.map_err(|err| anyhow!("failed to ensure capability set: {:?}", err))?;
let membership = NewUserMembership {
id: Uuid::new_v4(),
user_id: user.id,
tenant_id,
capability_set_id: Some(capability_set.id),
};
diesel::insert_into(crate::schema::user_memberships::table)
.values(&membership)
.execute(conn)
.context("failed to insert user membership")?;
Ok(user.id)
})
.await?;
Ok(user_id)
}
pub async fn insert_passkey(&self, user_id: Uuid, nickname: Option<&str>) -> Result<Uuid> {
let passkey_id = Uuid::new_v4();
let nickname = nickname.map(|value| value.to_string());
self.with_conn(move |conn| {
let credential_id = passkey_id.as_bytes().to_vec();
let public_key = passkey_id.as_bytes().iter().copied().collect::<Vec<u8>>();
let passkey = NewUserPasskey {
id: passkey_id,
user_id,
credential_id,
public_key,
credential: json!({ "dummy": passkey_id.to_string() }),
sign_count: 0,
transports: vec![Some("usb".to_string())],
aaguid: None,
nickname,
};
diesel::insert_into(crate::schema::user_passkeys::table)
.values(&passkey)
.execute(conn)
.context("failed to insert passkey")?;
Ok(passkey_id)
})
.await
}
async fn ensure_default_tenant(&self) -> Result<Uuid> {
let name_value = TEST_TENANT_NAME.to_string();
let quickwit_enabled = self.state.config.quickwit_endpoint.is_some();
let tenant_id = self
.with_conn(move |conn| {
use crate::schema::tenants::dsl as tenants_dsl;
let existing = tenants_dsl::tenants
.filter(tenants_dsl::name.eq(&name_value))
.first::<Tenant>(conn)
.optional()
.context("failed to load default tenant")?;
let tenant_id = if let Some(current) = existing {
let desired_root = current
.storage_root
.clone()
.filter(|root| root.ends_with('/'))
.unwrap_or_else(|| format!("test-tenants/{}/", current.id));
if current.storage_root.as_deref() != Some(desired_root.as_str()) {
diesel::update(tenants_dsl::tenants.filter(tenants_dsl::id.eq(current.id)))
.set(tenants_dsl::storage_root.eq(Some(desired_root)))
.execute(conn)
.context("failed to update default tenant storage root")?;
}
current.id
} else {
let new_id = Uuid::new_v4();
let root = format!("test-tenants/{}/", new_id);
let quickwit_value = if quickwit_enabled {
Some(format!("documents-{}", new_id))
} else {
None
};
diesel::insert_into(tenants_dsl::tenants)
.values((
tenants_dsl::id.eq(new_id),
tenants_dsl::name.eq(&name_value),
tenants_dsl::storage_root.eq(Some(root)),
tenants_dsl::quickwit_index.eq(quickwit_value),
tenants_dsl::status.eq(TenantStatus::Active),
))
.execute(conn)
.context("failed to insert default tenant")?;
new_id
};
Ok(tenant_id)
})
.await?;
let mut conn = self
.state
.db_for_tenant(tenant_id)
.map_err(|err| anyhow!("failed to scope tenant connection: {err:?}"))?;
ensure_capability_set(&mut conn, tenant_id, owner_capabilities())
.map_err(|err| anyhow!("ensure owner capability set: {err:?}"))?;
ensure_capability_set(&mut conn, tenant_id, user_capabilities())
.map_err(|err| anyhow!("ensure user capability set: {err:?}"))?;
ensure_capability_set(&mut conn, tenant_id, readonly_capabilities())
.map_err(|err| anyhow!("ensure readonly capability set: {err:?}"))?;
ensure_capability_set(&mut conn, tenant_id, webdav_capabilities())
.map_err(|err| anyhow!("ensure webdav capability set: {err:?}"))?;
Ok(tenant_id)
}
pub async fn login_token(&self, username: &str, _password: &str) -> Result<String> {
let (access_token, _, _) = self.create_session(username).await?;
Ok(access_token)
}
pub async fn create_session(&self, username: &str) -> Result<(String, String, Uuid)> {
let username = username.to_string();
let state = self.state.clone();
self.with_conn(move |conn| {
use crate::schema::capability_sets::dsl as capability_sets_dsl;
use crate::schema::tenants::dsl as tenants_dsl;
use crate::schema::user_memberships::dsl as memberships_dsl;
use crate::schema::users::dsl as users_dsl;
let user: User = users_dsl::users
.filter(users_dsl::username.eq(&username))
.first(conn)?;
let membership: UserMembership = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(user.id))
.first(conn)?;
let tenant: Tenant = tenants_dsl::tenants
.find(membership.tenant_id)
.first(conn)?;
let capability_set_id = membership
.capability_set_id
.ok_or_else(|| anyhow!("membership missing capability set"))?;
let cap_version = capability_sets_dsl::capability_sets
.find(capability_set_id)
.select(capability_sets_dsl::cap_version)
.first::<i32>(conn)?;
let now = Utc::now();
let session_id = Uuid::new_v4();
let access_token = state
.jwt
.generate_token(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(session_dsl::user_sessions)
.values(&new_session)
.execute(conn)?;
let cookie = format!("refresh_token={session_value}");
Ok((access_token, cookie, tenant.id))
})
.await
}
pub async fn clear_jobs(&self) -> Result<()> {
self.with_conn(|conn| {
use crate::schema::jobs::dsl::jobs as jobs_table;
diesel::delete(jobs_table)
.execute(conn)
.context("failed to clear jobs")?;
Ok(())
})
.await
}
pub async fn jobs_by_type(&self, ty: &str) -> Result<Vec<Job>> {
let ty = ty.to_string();
self.with_conn(move |conn| {
use crate::schema::jobs::dsl::{job_type as job_type_col, jobs as jobs_table};
let rows = jobs_table
.filter(job_type_col.eq(&ty))
.load::<Job>(conn)
.context("failed to load jobs")?;
Ok(rows)
})
.await
}
pub async fn post_json<T: Serialize + ?Sized>(
&self,
path: &str,
payload: &T,
token: Option<&str>,
) -> Result<hyper::Response<Body>> {
self.post_json_with_cookie(path, payload, token, None).await
}
pub async fn post_json_with_cookie<T: Serialize + ?Sized>(
&self,
path: &str,
payload: &T,
token: Option<&str>,
cookie: Option<&str>,
) -> Result<hyper::Response<Body>> {
let body = serde_json::to_vec(payload)?;
let mut builder = Request::builder()
.method(Method::POST)
.uri(path)
.header("content-type", "application/json");
if let Some(token) = token {
builder = builder.header("authorization", format!("Bearer {token}"));
}
if let Some(cookie) = cookie {
builder = builder.header(header::COOKIE, cookie);
}
let request = builder.body(Body::from(body))?;
Ok(self
.router
.clone()
.oneshot(request)
.await
.expect("infallible response"))
}
pub async fn patch_json<T: Serialize + ?Sized>(
&self,
path: &str,
payload: &T,
token: Option<&str>,
) -> Result<hyper::Response<Body>> {
let body = serde_json::to_vec(payload)?;
let mut builder = Request::builder()
.method(Method::PATCH)
.uri(path)
.header("content-type", "application/json");
if let Some(token) = token {
builder = builder.header("authorization", format!("Bearer {token}"));
}
let request = builder.body(Body::from(body))?;
Ok(self
.router
.clone()
.oneshot(request)
.await
.expect("infallible response"))
}
pub async fn get(&self, path: &str, token: Option<&str>) -> Result<hyper::Response<Body>> {
let mut builder = Request::builder().method(Method::GET).uri(path);
if let Some(token) = token {
builder = builder.header("authorization", format!("Bearer {token}"));
}
let request = builder.body(Body::empty())?;
Ok(self
.router
.clone()
.oneshot(request)
.await
.expect("infallible response"))
}
pub async fn delete(&self, path: &str, token: Option<&str>) -> Result<hyper::Response<Body>> {
let builder = Request::builder().method(Method::DELETE).uri(path);
let builder = if let Some(token) = token {
builder.header("authorization", format!("Bearer {token}"))
} else {
builder
};
let request = builder.body(Body::empty())?;
Ok(self
.router
.clone()
.oneshot(request)
.await
.expect("infallible response"))
}
pub async fn upload_document(
&self,
path: &str,
filename: &str,
content_type: &str,
data: &[u8],
folder_id: Option<Uuid>,
token: &str,
) -> Result<hyper::Response<Body>> {
let extras = UploadExtras::empty();
self.upload_document_with_extras(
path,
filename,
content_type,
data,
folder_id,
extras,
token,
)
.await
}
pub async fn upload_document_with_options(
&self,
path: &str,
filename: &str,
content_type: &str,
data: &[u8],
folder_id: Option<Uuid>,
title: Option<&str>,
metadata_json: Option<&str>,
token: &str,
) -> Result<hyper::Response<Body>> {
let extras = UploadExtras {
title,
metadata_json,
tag_ids_json: None,
correspondents_json: None,
issued_at: None,
skip_existing: None,
};
self.upload_document_with_extras(
path,
filename,
content_type,
data,
folder_id,
extras,
token,
)
.await
}
pub async fn upload_document_with_extras(
&self,
path: &str,
filename: &str,
content_type: &str,
data: &[u8],
folder_id: Option<Uuid>,
extras: UploadExtras<'_>,
token: &str,
) -> Result<hyper::Response<Body>> {
let boundary = format!("boundary-{}", Uuid::new_v4());
let mut body = Vec::new();
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(
format!(
"Content-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\n",
filename
)
.as_bytes(),
);
body.extend(format!("Content-Type: {}\r\n\r\n", content_type).as_bytes());
body.extend(data);
body.extend(b"\r\n");
if let Some(folder) = folder_id {
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(b"Content-Disposition: form-data; name=\"folder_id\"\r\n\r\n");
body.extend(folder.to_string().as_bytes());
body.extend(b"\r\n");
}
if let Some(title_value) = extras.title {
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(b"Content-Disposition: form-data; name=\"title\"\r\n\r\n");
body.extend(title_value.as_bytes());
body.extend(b"\r\n");
}
if let Some(metadata_value) = extras.metadata_json {
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(b"Content-Disposition: form-data; name=\"metadata\"\r\n\r\n");
body.extend(metadata_value.as_bytes());
body.extend(b"\r\n");
}
if let Some(tag_ids_value) = extras.tag_ids_json {
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(b"Content-Disposition: form-data; name=\"tag_ids\"\r\n\r\n");
body.extend(tag_ids_value.as_bytes());
body.extend(b"\r\n");
}
if let Some(correspondents_value) = extras.correspondents_json {
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(b"Content-Disposition: form-data; name=\"correspondents\"\r\n\r\n");
body.extend(correspondents_value.as_bytes());
body.extend(b"\r\n");
}
if let Some(issued_at_value) = extras.issued_at {
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(b"Content-Disposition: form-data; name=\"issued_at\"\r\n\r\n");
body.extend(issued_at_value.as_bytes());
body.extend(b"\r\n");
}
if let Some(skip_flag) = extras.skip_existing {
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(b"Content-Disposition: form-data; name=\"skip_existing\"\r\n\r\n");
body.extend(if skip_flag {
b"true".as_ref()
} else {
b"false".as_ref()
});
body.extend(b"\r\n");
}
body.extend(format!("--{boundary}--\r\n").as_bytes());
let builder = Request::builder()
.method(Method::POST)
.uri(path)
.header(
"content-type",
format!("multipart/form-data; boundary={boundary}"),
)
.header("authorization", format!("Bearer {token}"));
let request = builder.body(Body::from(body))?;
Ok(self
.router
.clone()
.oneshot(request)
.await
.expect("infallible response"))
}
pub async fn with_conn<F, T>(&self, f: F) -> Result<T>
where
F: FnOnce(&mut PgConnection) -> Result<T> + Send + 'static,
T: Send + 'static,
{
let pool = self.state.pool.clone();
tokio::task::spawn_blocking(move || {
let mut conn = pool
.get()
.map_err(|err| anyhow!("failed to get database connection: {err}"))?;
f(&mut conn)
})
.await
.context("connection task panicked")?
}
}
pub struct UploadExtras<'a> {
pub title: Option<&'a str>,
pub metadata_json: Option<&'a str>,
pub tag_ids_json: Option<&'a str>,
pub correspondents_json: Option<&'a str>,
pub issued_at: Option<&'a str>,
pub skip_existing: Option<bool>,
}
impl<'a> UploadExtras<'a> {
pub fn empty() -> Self {
Self {
title: None,
metadata_json: None,
tag_ids_json: None,
correspondents_json: None,
issued_at: None,
skip_existing: None,
}
}
}
pub async fn acquire_db_lock() -> tokio::sync::MutexGuard<'static, ()> {
DB_LOCK.lock().await
}
pub async fn body_to_vec(body: Body) -> Result<Vec<u8>> {
let collected = body
.collect()
.await
.map_err(|err| anyhow!("failed to read response body: {err}"))?;
Ok(collected.to_bytes().to_vec())
}
#[cfg(test)]
mod helper_tests {
use super::*;
#[tokio::test]
async fn create_session_and_login_token_provide_access() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let username = "helper-login";
let password = "irrelevant";
app.insert_user(username, TestUserRole::Owner).await?;
let (access, refresh, refresh_id) = app.create_session(username).await?;
assert!(!access.is_empty(), "access token should not be empty");
assert!(!refresh.is_empty(), "refresh token should not be empty");
assert_ne!(
refresh_id,
Uuid::nil(),
"refresh token id should be assigned"
);
let bearer = app.login_token(username, password).await?;
assert!(!bearer.is_empty(), "login_token must yield bearer");
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn insert_passkey_and_upload_with_options_succeeds() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let username = "helper-passkey";
let password = "unused";
let user_id = app.insert_user(username, TestUserRole::Owner).await?;
let passkey_id = app.insert_passkey(user_id, Some("Laptop")).await?;
assert_ne!(passkey_id, Uuid::nil());
let bearer = app.login_token(username, password).await?;
let response = app
.upload_document_with_options(
"/api/documents",
"helper.txt",
"text/plain",
b"helper-content",
None,
Some("Helper Note"),
Some("{\"category\":\"note\"}"),
&bearer,
)
.await?;
assert!(response.status().is_success());
app.cleanup().await?;
Ok(())
}
}
async fn prepare_database(pool: &PgPool) -> Result<()> {
let pool = pool.clone();
tokio::task::spawn_blocking(move || -> Result<()> {
let mut conn = pool
.get()
.map_err(|err| anyhow!("failed to acquire connection: {err}"))?;
conn.batch_execute(RESET_DATABASE_SQL)
.map_err(|err| anyhow!("failed to reset schema: {err}"))?;
conn.batch_execute("DROP TABLE IF EXISTS __diesel_schema_migrations;")
.map_err(|err| anyhow!("failed to drop diesel schema table: {err}"))?;
conn.run_pending_migrations(MIGRATIONS)
.map_err(|err| anyhow!("failed to run migrations: {err}"))?;
truncate_all(&mut conn)?;
Ok(())
})
.await
.context("migration task panicked")?
}
fn truncate_all(conn: &mut PgConnection) -> Result<()> {
conn.batch_execute(
"TRUNCATE TABLE \
tenant.document_asset_objects, \
tenant.document_assets, \
tenant.document_correspondents, \
tenant.correspondents, \
tenant.document_tags, \
tenant.document_versions, \
tenant.documents, \
tenant.folders, \
shared.jobs, \
tenant.user_sessions, \
tenant.tags, \
tenant.api_tokens, \
shared.webauthn_challenges, \
shared.user_passkeys, \
tenant.user_memberships, \
shared.users, \
shared.magic_tokens, \
shared.tenants \
RESTART IDENTITY CASCADE;",
)
.context("failed to truncate tables")?;
Ok(())
}
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())
}
+39 -48
View File
@@ -1,15 +1,16 @@
mod common; use anyhow::{Context, Result};
use anyhow::Result;
use axum::body::Body; use axum::body::Body;
use axum::http::{header, Method, Request, StatusCode}; use axum::http::{header, Method, Request, StatusCode};
use base64::engine::general_purpose::STANDARD as BASE64; use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine; use base64::Engine;
use common::{acquire_db_lock, body_to_vec, TestApp, TestUserRole};
use diesel::prelude::*; use diesel::prelude::*;
use papercrate::models::ApiToken; use diesel::OptionalExtension;
use papercrate::auth::capability_sets;
use papercrate::models::{ApiCapability, ApiToken};
use papercrate::routes::webdav; use papercrate::routes::webdav;
use papercrate::schema::api_tokens; 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::Deserialize;
use serde_json::json; use serde_json::json;
use tower::ServiceExt; use tower::ServiceExt;
@@ -59,12 +60,6 @@ struct TenantView {
name: String, name: String,
} }
#[derive(Debug, Deserialize)]
struct CapabilitySetSummary {
id: Uuid,
slug: String,
}
#[tokio::test] #[tokio::test]
async fn api_token_crud_flow() -> Result<()> { async fn api_token_crud_flow() -> Result<()> {
let _guard = acquire_db_lock().await; let _guard = acquire_db_lock().await;
@@ -356,46 +351,42 @@ async fn ensure_capability_set_slug(
slug: &str, slug: &str,
capabilities: &[&str], capabilities: &[&str],
) -> Result<Uuid> { ) -> Result<Uuid> {
if let Some(existing) = find_capability_set_slug(app, access_token, slug).await? { 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); return Ok(existing);
} }
let response = app let created =
.post_json( capability_sets::create_capability_set(conn, tenant_id, &slug, caps_for_insert)
"/api/capability-sets", .map_err(|err| {
&json!({ anyhow::anyhow!("failed to create capability set '{slug}': {err:?}")
"slug": slug, })?;
"capabilities": capabilities, Ok(created.id)
}), })
Some(access_token), .await
)
.await?;
assert_eq!(response.status(), StatusCode::CREATED);
let body = body_to_vec(response.into_body()).await?;
let summary: CapabilitySetSummary = serde_json::from_slice(&body)?;
Ok(summary.id)
}
async fn find_capability_set_slug(
app: &TestApp,
access_token: &str,
slug: &str,
) -> Result<Option<Uuid>> {
let sets = list_capability_sets(app, access_token).await?;
Ok(sets
.into_iter()
.find(|set| set.slug == slug)
.map(|set| set.id))
}
async fn list_capability_sets(
app: &TestApp,
access_token: &str,
) -> Result<Vec<CapabilitySetSummary>> {
let response = app.get("/api/capability-sets", 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 exchange_token(app: &TestApp, api_token: &str) -> Result<LoginResponseView> { async fn exchange_token(app: &TestApp, api_token: &str) -> Result<LoginResponseView> {
let response = app let response = app
+18 -20
View File
@@ -1,9 +1,6 @@
mod common;
use anyhow::{anyhow, Context, Result}; use anyhow::{anyhow, Context, Result};
use axum::http::{header::SET_COOKIE, StatusCode}; use axum::http::{header::SET_COOKIE, StatusCode};
use chrono::{Duration as ChronoDuration, Utc}; use chrono::{Duration as ChronoDuration, Utc};
use common::{acquire_db_lock, body_to_vec, TestApp, TestUserRole};
use diesel::prelude::*; use diesel::prelude::*;
use papercrate::auth::capability_sets::{ensure_capability_set, owner_capabilities}; use papercrate::auth::capability_sets::{ensure_capability_set, owner_capabilities};
use papercrate::auth::jwt::{AccessTokenContext, PrincipalKind}; use papercrate::auth::jwt::{AccessTokenContext, PrincipalKind};
@@ -14,8 +11,8 @@ use papercrate::auth::passkeys::{
use papercrate::models::{NewUserMembership, NewUserSession, TenantStatus, UserPasskey}; use papercrate::models::{NewUserMembership, NewUserSession, TenantStatus, UserPasskey};
use papercrate::openapi::schemas::PasskeySummary; use papercrate::openapi::schemas::PasskeySummary;
use papercrate::schema::{capability_sets, tenants, user_memberships, user_sessions, users}; use papercrate::schema::{capability_sets, tenants, user_memberships, user_sessions, users};
use rand::rngs::OsRng; use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole};
use rand::RngCore; use rand::{rngs::OsRng, TryRngCore};
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
use sha2::{Digest, Sha256}; use sha2::{Digest, Sha256};
@@ -55,14 +52,6 @@ struct SignupStartResponse {
challenge: RegistrationChallengeResponse, challenge: RegistrationChallengeResponse,
} }
#[derive(Deserialize)]
struct TenantSelectionResponse {
#[serde(rename = "access_token")]
_access_token: String,
#[serde(rename = "tenants")]
_tenants: Vec<TenantSummary>,
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct TenantListResponse { struct TenantListResponse {
tenants: Vec<TenantSummary>, tenants: Vec<TenantSummary>,
@@ -226,8 +215,9 @@ async fn passkey_login_start_requires_passkey() -> Result<()> {
let _lock = acquire_db_lock().await; let _lock = acquire_db_lock().await;
let app = TestApp::new().await?; let app = TestApp::new().await?;
let password = "secret"; let _password = "secret";
app.insert_user("passkey-login", TestUserRole::Owner).await?; app.insert_user("passkey-login", TestUserRole::Owner)
.await?;
let payload = PasskeyLoginStartPayload { let payload = PasskeyLoginStartPayload {
username: "passkey-login".to_string(), username: "passkey-login".to_string(),
@@ -288,7 +278,9 @@ async fn list_passkeys_returns_entries() -> Result<()> {
let app = TestApp::new().await?; let app = TestApp::new().await?;
let password = "secret"; let password = "secret";
let user_id = app.insert_user("passkey-owner", TestUserRole::Owner).await?; let user_id = app
.insert_user("passkey-owner", TestUserRole::Owner)
.await?;
app.insert_passkey(user_id, Some("Laptop")).await?; app.insert_passkey(user_id, Some("Laptop")).await?;
let (session, _) = login_with_session(&app, "passkey-owner", password).await?; let (session, _) = login_with_session(&app, "passkey-owner", password).await?;
@@ -314,7 +306,9 @@ async fn delete_passkey_soft_revokes() -> Result<()> {
let app = TestApp::new().await?; let app = TestApp::new().await?;
let password = "secret"; let password = "secret";
let user_id = app.insert_user("passkey-delete", TestUserRole::Owner).await?; let user_id = app
.insert_user("passkey-delete", TestUserRole::Owner)
.await?;
let passkey_id = app.insert_passkey(user_id, Some("Phone")).await?; let passkey_id = app.insert_passkey(user_id, Some("Phone")).await?;
app.insert_passkey(user_id, Some("Backup")).await?; app.insert_passkey(user_id, Some("Backup")).await?;
let (session, _) = login_with_session(&app, "passkey-delete", password).await?; let (session, _) = login_with_session(&app, "passkey-delete", password).await?;
@@ -349,7 +343,9 @@ async fn delete_passkey_prevents_last() -> Result<()> {
let app = TestApp::new().await?; let app = TestApp::new().await?;
let password = "secret"; let password = "secret";
let user_id = app.insert_user("passkey-guard", TestUserRole::Owner).await?; let user_id = app
.insert_user("passkey-guard", TestUserRole::Owner)
.await?;
let first_id = app.insert_passkey(user_id, Some("Key A")).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 last_id = app.insert_passkey(user_id, Some("Key B")).await?;
let (session, _) = login_with_session(&app, "passkey-guard", password).await?; let (session, _) = login_with_session(&app, "passkey-guard", password).await?;
@@ -408,7 +404,7 @@ async fn login_rejects_invalid_password() -> Result<()> {
let _lock = acquire_db_lock().await; let _lock = acquire_db_lock().await;
let app = TestApp::new().await?; let app = TestApp::new().await?;
let password = "valid"; let _password = "valid";
app.insert_user("robin", TestUserRole::Owner).await?; app.insert_user("robin", TestUserRole::Owner).await?;
let payload = json!({ "username": "robin", "password": "wrong" }); let payload = json!({ "username": "robin", "password": "wrong" });
@@ -671,7 +667,9 @@ fn extract_refresh_cookie(headers: &axum::http::HeaderMap) -> Result<String> {
fn generate_session_token() -> String { fn generate_session_token() -> String {
let mut bytes = [0u8; 32]; let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut bytes); OsRng
.try_fill_bytes(&mut bytes)
.expect("failed to read random bytes");
hex::encode(bytes) hex::encode(bytes)
} }
+7 -9
View File
@@ -1,10 +1,8 @@
mod common;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use axum::http::StatusCode; use axum::http::StatusCode;
use common::{TestApp, TestUserRole};
use diesel::prelude::*; use diesel::prelude::*;
use papercrate::models::ApiCapability; use papercrate::models::ApiCapability;
use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole};
use serde_json::json; use serde_json::json;
async fn set_user_capabilities( async fn set_user_capabilities(
@@ -38,7 +36,7 @@ async fn set_user_capabilities(
#[tokio::test] #[tokio::test]
async fn documents_routes_enforce_capabilities() -> Result<()> { async fn documents_routes_enforce_capabilities() -> Result<()> {
let _lock = common::acquire_db_lock().await; let _lock = acquire_db_lock().await;
let app = TestApp::new().await?; let app = TestApp::new().await?;
let password = "limited-docs"; let password = "limited-docs";
@@ -61,12 +59,12 @@ async fn documents_routes_enforce_capabilities() -> Result<()> {
) )
.await?; .await?;
assert_eq!(upload.status(), StatusCode::FORBIDDEN); assert_eq!(upload.status(), StatusCode::FORBIDDEN);
let upload_body = common::body_to_vec(upload.into_body()).await?; let upload_body = body_to_vec(upload.into_body()).await?;
assert!(String::from_utf8_lossy(&upload_body).contains("missing")); assert!(String::from_utf8_lossy(&upload_body).contains("missing"));
let capability_sets = app.get("/api/capability-sets", Some(&token)).await?; let capability_sets = app.get("/api/capability-sets", Some(&token)).await?;
assert_eq!(capability_sets.status(), StatusCode::FORBIDDEN); assert_eq!(capability_sets.status(), StatusCode::FORBIDDEN);
let caps_body = common::body_to_vec(capability_sets.into_body()).await?; let caps_body = body_to_vec(capability_sets.into_body()).await?;
assert!(String::from_utf8_lossy(&caps_body).contains("missing")); assert!(String::from_utf8_lossy(&caps_body).contains("missing"));
app.cleanup().await?; app.cleanup().await?;
@@ -75,11 +73,11 @@ async fn documents_routes_enforce_capabilities() -> Result<()> {
#[tokio::test] #[tokio::test]
async fn capability_set_routes_require_write_privilege() -> Result<()> { async fn capability_set_routes_require_write_privilege() -> Result<()> {
let _lock = common::acquire_db_lock().await; let _lock = acquire_db_lock().await;
let app = TestApp::new().await?; let app = TestApp::new().await?;
let password = "caps-reader"; let password = "caps-reader";
let user_id = app.insert_user("caps-reader", TestUserRole::Owner).await?; let user_id = app.insert_user("caps-reader", TestUserRole::Member).await?;
set_user_capabilities(&app, user_id, &[ApiCapability::CapabilitySetsRead]).await?; set_user_capabilities(&app, user_id, &[ApiCapability::CapabilitySetsRead]).await?;
let token = app.login_token("caps-reader", password).await?; let token = app.login_token("caps-reader", password).await?;
@@ -98,7 +96,7 @@ async fn capability_set_routes_require_write_privilege() -> Result<()> {
) )
.await?; .await?;
assert_eq!(create.status(), StatusCode::FORBIDDEN); assert_eq!(create.status(), StatusCode::FORBIDDEN);
let create_body = common::body_to_vec(create.into_body()).await?; let create_body = body_to_vec(create.into_body()).await?;
assert!(String::from_utf8_lossy(&create_body).contains("missing")); assert!(String::from_utf8_lossy(&create_body).contains("missing"));
app.cleanup().await?; app.cleanup().await?;
+1 -3
View File
@@ -1,8 +1,6 @@
mod common;
use anyhow::Result; use anyhow::Result;
use axum::http::StatusCode; use axum::http::StatusCode;
use common::{acquire_db_lock, body_to_vec, TestApp, TestUserRole}; use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole};
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
use uuid::Uuid; use uuid::Uuid;
+1 -902
View File
@@ -1,902 +1 @@
use std::collections::HashMap; pub use papercrate::test_support::*;
use std::env;
use std::sync::Arc;
use std::time::Duration;
use anyhow::{anyhow, ensure, Context, Result};
use async_trait::async_trait;
use axum::body::Body;
use axum::http::{header, Method, Request};
use axum::Router;
use chrono::{Duration as ChronoDuration, Utc};
use diesel::connection::SimpleConnection;
use diesel::prelude::*;
use diesel::OptionalExtension;
use diesel::PgConnection;
use diesel_migrations::{embed_migrations, EmbeddedMigrations, MigrationHarness};
use http_body_util::BodyExt;
use once_cell::sync::Lazy;
use papercrate::auth::capability_sets::{
ensure_capability_set, owner_capabilities, readonly_capabilities, user_capabilities,
webdav_capabilities,
};
use papercrate::auth::jwt::{AccessTokenContext, JwtService, PrincipalKind};
use papercrate::config::AppConfig;
use papercrate::db::{self, PgPool};
use papercrate::models::{
Job, NewUser, NewUserMembership, NewUserPasskey, NewUserSession, Tenant, TenantStatus, User,
UserMembership,
};
use papercrate::routes;
use papercrate::schema::user_sessions::dsl as session_dsl;
use papercrate::state::AppState;
use papercrate::storage::ObjectStorage;
use rand::rngs::OsRng;
use rand::RngCore;
use serde::Serialize;
use serde_json::{self, json};
use sha2::{Digest, Sha256};
use tokio::sync::Mutex;
use tower::util::ServiceExt;
use uuid::Uuid;
const MIGRATIONS: EmbeddedMigrations = embed_migrations!("migrations");
const RESET_DATABASE_SQL: &str = "DROP SCHEMA IF EXISTS tenant CASCADE;\n\
DROP SCHEMA IF EXISTS shared CASCADE;\n\
DROP SCHEMA IF EXISTS public CASCADE;\n\
CREATE SCHEMA public;\n\
GRANT ALL ON SCHEMA public TO public;";
static DB_LOCK: Lazy<Mutex<()>> = Lazy::new(|| Mutex::new(()));
const TEST_TENANT_NAME: &str = "test_tenant";
#[derive(Clone, Copy, Debug)]
pub enum TestUserRole {
Owner,
Member,
WebDav,
}
#[allow(dead_code)]
#[derive(Clone)]
pub struct StoredObject {
pub key: String,
pub bytes: Vec<u8>,
pub content_type: Option<String>,
pub content_disposition: Option<String>,
}
#[derive(Default)]
pub struct FakeStorage {
objects: Mutex<HashMap<String, StoredObject>>,
}
#[async_trait]
impl ObjectStorage for FakeStorage {
async fn put_object(
&self,
key: &str,
bytes: Vec<u8>,
content_type: Option<String>,
content_disposition: Option<String>,
) -> Result<()> {
let stored = StoredObject {
key: key.to_string(),
bytes,
content_type,
content_disposition,
};
let mut guard = self.objects.lock().await;
guard.insert(stored.key.clone(), stored);
Ok(())
}
async fn presign_get_object(
&self,
key: &str,
expires_in: Duration,
_response_content_disposition: Option<&str>,
) -> Result<String> {
let guard = self.objects.lock().await;
ensure!(guard.contains_key(key), "object {key} missing");
Ok(format!(
"https://fake-storage/{key}?expires_in={}",
expires_in.as_secs()
))
}
async fn get_object(&self, key: &str) -> Result<Vec<u8>> {
let guard = self.objects.lock().await;
guard
.get(key)
.map(|obj| obj.bytes.clone())
.ok_or_else(|| anyhow!("object {key} missing"))
}
async fn delete_object(&self, key: &str) -> Result<()> {
let mut guard = self.objects.lock().await;
guard.remove(key);
Ok(())
}
}
impl FakeStorage {
#[allow(dead_code)]
pub async fn get(&self, key: &str) -> Option<StoredObject> {
let guard = self.objects.lock().await;
guard.get(key).cloned()
}
#[allow(dead_code)]
pub async fn object_count(&self) -> usize {
let guard = self.objects.lock().await;
guard.len()
}
}
pub struct TestApp {
pub state: AppState,
router: Router,
storage: Arc<FakeStorage>,
}
impl TestApp {
pub async fn new() -> Result<Self> {
Self::with_config(|_| {}).await
}
pub async fn with_config<F>(configure: F) -> Result<Self>
where
F: FnOnce(&mut AppConfig),
{
let database_url = env::var("TEST_DATABASE_URL")
.context("TEST_DATABASE_URL must be set for integration tests")?;
let mut config = AppConfig {
database_url: database_url.clone(),
database_max_pool_size: db::DEFAULT_MAX_POOL_SIZE,
server_host: "127.0.0.1".to_string(),
server_port: 0,
webdav_host: "127.0.0.1".to_string(),
webdav_port: 0,
jwt_secret: "test-secret".to_string(),
jwt_issuer: "test-issuer".to_string(),
jwt_audience: "test-audience".to_string(),
jwt_expiry_minutes: 60,
download_token_audience: "test-download".to_string(),
download_token_expiry_minutes: 60,
refresh_token_expiry_days: 30,
refresh_cookie_secure: false,
refresh_cookie_domain: None,
cors_allowed_origin: None,
proxy_downloads: false,
aws_endpoint_url: None,
aws_access_key_id: None,
aws_secret_access_key: None,
aws_region: "us-east-1".to_string(),
s3_bucket: "test-bucket".to_string(),
quickwit_endpoint: None,
quickwit_index: None,
worker_max_document_bytes: 200 * 1024 * 1024,
upload_body_limit_bytes: 128 * 1024 * 1024,
webauthn_rp_id: Some("localhost".to_string()),
webauthn_origin: Some("http://localhost".to_string()),
webauthn_rp_name: "Papercrate".to_string(),
};
configure(&mut config);
let pool = db::init_pool_with_size(&config.database_url, config.database_max_pool_size)?;
prepare_database(&pool).await?;
let storage = Arc::new(FakeStorage::default());
let storage_for_state: Arc<dyn ObjectStorage> = storage.clone();
let jwt = JwtService::from_config(&config)?;
let state = AppState::new(pool.clone(), config, storage_for_state, jwt);
let router = routes::create_router(state.clone());
let app = Self {
state,
router,
storage,
};
app.ensure_default_tenant().await?;
Ok(app)
}
pub async fn cleanup(&self) -> Result<()> {
let pool = self.state.pool.clone();
let _ = tokio::task::spawn_blocking(move || -> Result<()> {
let mut conn = pool
.get()
.map_err(|err| anyhow!("failed to get cleanup connection: {err}"))?;
truncate_all(&mut conn)?;
Ok(())
})
.await
.context("cleanup task panicked")?;
self.ensure_default_tenant().await?;
Ok(())
}
#[allow(dead_code)]
pub async fn tenant_id(&self) -> Result<Uuid> {
self.ensure_default_tenant().await
}
#[allow(dead_code)]
pub fn storage(&self) -> Arc<FakeStorage> {
self.storage.clone()
}
#[allow(dead_code)]
pub async fn storage_key_for(&self, key: &str) -> Result<String> {
self.ensure_default_tenant().await?;
let tenant = self
.state
.tenants
.get_by_name(TEST_TENANT_NAME)
.map_err(|err| anyhow!("default tenant not found: {:?}", err))?;
let root = tenant
.storage_root
.clone()
.ok_or_else(|| anyhow!("default tenant missing storage root"))?;
Ok(format!("{}{}", root, key))
}
pub async fn insert_user(&self, username: &str, role: TestUserRole) -> Result<Uuid> {
let username = username.to_string();
let tenant_id = self.ensure_default_tenant().await?;
let user_id = self
.with_conn(move |conn| {
let user = NewUser {
id: Uuid::new_v4(),
username,
};
diesel::insert_into(papercrate::schema::users::table)
.values(&user)
.execute(conn)
.context("failed to insert user")?;
let capabilities = match role {
TestUserRole::Owner => owner_capabilities(),
TestUserRole::Member => user_capabilities(),
TestUserRole::WebDav => webdav_capabilities(),
};
let capability_set = ensure_capability_set(conn, tenant_id, capabilities)
.map_err(|err| anyhow!("failed to ensure capability set: {:?}", err))?;
let membership = NewUserMembership {
id: Uuid::new_v4(),
user_id: user.id,
tenant_id,
capability_set_id: Some(capability_set.id),
};
diesel::insert_into(papercrate::schema::user_memberships::table)
.values(&membership)
.execute(conn)
.context("failed to insert user membership")?;
Ok(user.id)
})
.await?;
Ok(user_id)
}
#[cfg(test)]
pub async fn insert_passkey(&self, user_id: Uuid, nickname: Option<&str>) -> Result<Uuid> {
let passkey_id = Uuid::new_v4();
let nickname = nickname.map(|value| value.to_string());
self.with_conn(move |conn| {
let credential_id = passkey_id.as_bytes().to_vec();
let public_key = passkey_id.as_bytes().iter().copied().collect::<Vec<u8>>();
let passkey = NewUserPasskey {
id: passkey_id,
user_id,
credential_id,
public_key,
credential: json!({ "dummy": passkey_id.to_string() }),
sign_count: 0,
transports: vec![Some("usb".to_string())],
aaguid: None,
nickname,
};
diesel::insert_into(papercrate::schema::user_passkeys::table)
.values(&passkey)
.execute(conn)
.context("failed to insert passkey")?;
Ok(passkey_id)
})
.await
}
async fn ensure_default_tenant(&self) -> Result<Uuid> {
let name_value = TEST_TENANT_NAME.to_string();
let quickwit_enabled = self.state.config.quickwit_endpoint.is_some();
let tenant_id = self
.with_conn(move |conn| {
use papercrate::schema::tenants::dsl as tenants_dsl;
let existing = tenants_dsl::tenants
.filter(tenants_dsl::name.eq(&name_value))
.first::<Tenant>(conn)
.optional()
.context("failed to load default tenant")?;
let tenant_id = if let Some(current) = existing {
let desired_root = current
.storage_root
.clone()
.filter(|root| root.ends_with('/'))
.unwrap_or_else(|| format!("test-tenants/{}/", current.id));
if current.storage_root.as_deref() != Some(desired_root.as_str()) {
diesel::update(tenants_dsl::tenants.filter(tenants_dsl::id.eq(current.id)))
.set(tenants_dsl::storage_root.eq(Some(desired_root)))
.execute(conn)
.context("failed to update default tenant storage root")?;
}
current.id
} else {
let new_id = Uuid::new_v4();
let root = format!("test-tenants/{}/", new_id);
let quickwit_value = if quickwit_enabled {
Some(format!("documents-{}", new_id))
} else {
None
};
diesel::insert_into(tenants_dsl::tenants)
.values((
tenants_dsl::id.eq(new_id),
tenants_dsl::name.eq(&name_value),
tenants_dsl::storage_root.eq(Some(root)),
tenants_dsl::quickwit_index.eq(quickwit_value),
tenants_dsl::status.eq(TenantStatus::Active),
))
.execute(conn)
.context("failed to insert default tenant")?;
new_id
};
Ok(tenant_id)
})
.await?;
let mut conn = self
.state
.db_for_tenant(tenant_id)
.map_err(|err| anyhow!("failed to scope tenant connection: {err:?}"))?;
ensure_capability_set(&mut conn, tenant_id, owner_capabilities())
.map_err(|err| anyhow!("ensure owner capability set: {err:?}"))?;
ensure_capability_set(&mut conn, tenant_id, user_capabilities())
.map_err(|err| anyhow!("ensure user capability set: {err:?}"))?;
ensure_capability_set(&mut conn, tenant_id, readonly_capabilities())
.map_err(|err| anyhow!("ensure readonly capability set: {err:?}"))?;
ensure_capability_set(&mut conn, tenant_id, webdav_capabilities())
.map_err(|err| anyhow!("ensure webdav capability set: {err:?}"))?;
Ok(tenant_id)
}
pub async fn login_token(&self, username: &str, _password: &str) -> Result<String> {
let (access_token, _, _) = self.create_session(username).await?;
Ok(access_token)
}
pub async fn create_session(&self, username: &str) -> Result<(String, String, Uuid)> {
let username = username.to_string();
let state = self.state.clone();
self.with_conn(move |conn| {
use papercrate::schema::capability_sets::dsl as capability_sets_dsl;
use papercrate::schema::tenants::dsl as tenants_dsl;
use papercrate::schema::user_memberships::dsl as memberships_dsl;
use papercrate::schema::users::dsl as users_dsl;
let user: User = users_dsl::users
.filter(users_dsl::username.eq(&username))
.first(conn)?;
let membership: UserMembership = memberships_dsl::user_memberships
.filter(memberships_dsl::user_id.eq(user.id))
.first(conn)?;
let tenant: Tenant = tenants_dsl::tenants
.find(membership.tenant_id)
.first(conn)?;
let capability_set_id = membership
.capability_set_id
.ok_or_else(|| anyhow!("membership missing capability set"))?;
let cap_version = capability_sets_dsl::capability_sets
.find(capability_set_id)
.select(capability_sets_dsl::cap_version)
.first::<i32>(conn)?;
let now = Utc::now();
let session_id = Uuid::new_v4();
let access_token = state
.jwt
.generate_token(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(session_dsl::user_sessions)
.values(&new_session)
.execute(conn)?;
let cookie = format!("refresh_token={session_value}");
Ok((access_token, cookie, tenant.id))
})
.await
}
#[allow(dead_code)]
pub async fn clear_jobs(&self) -> Result<()> {
self.with_conn(|conn| {
use papercrate::schema::jobs::dsl::jobs as jobs_table;
diesel::delete(jobs_table)
.execute(conn)
.context("failed to clear jobs")?;
Ok(())
})
.await
}
#[allow(dead_code)]
pub async fn jobs_by_type(&self, ty: &str) -> Result<Vec<Job>> {
let ty = ty.to_string();
self.with_conn(move |conn| {
use papercrate::schema::jobs::dsl::{job_type as job_type_col, jobs as jobs_table};
let rows = jobs_table
.filter(job_type_col.eq(&ty))
.load::<Job>(conn)
.context("failed to load jobs")?;
Ok(rows)
})
.await
}
pub async fn post_json<T: Serialize + ?Sized>(
&self,
path: &str,
payload: &T,
token: Option<&str>,
) -> Result<hyper::Response<Body>> {
self.post_json_with_cookie(path, payload, token, None).await
}
pub async fn post_json_with_cookie<T: Serialize + ?Sized>(
&self,
path: &str,
payload: &T,
token: Option<&str>,
cookie: Option<&str>,
) -> Result<hyper::Response<Body>> {
let body = serde_json::to_vec(payload)?;
let mut builder = Request::builder()
.method(Method::POST)
.uri(path)
.header("content-type", "application/json");
if let Some(token) = token {
builder = builder.header("authorization", format!("Bearer {token}"));
}
if let Some(cookie) = cookie {
builder = builder.header(header::COOKIE, cookie);
}
let request = builder.body(Body::from(body))?;
Ok(self
.router
.clone()
.oneshot(request)
.await
.expect("infallible response"))
}
#[allow(dead_code)]
pub async fn patch_json<T: Serialize + ?Sized>(
&self,
path: &str,
payload: &T,
token: Option<&str>,
) -> Result<hyper::Response<Body>> {
let body = serde_json::to_vec(payload)?;
let mut builder = Request::builder()
.method(Method::PATCH)
.uri(path)
.header("content-type", "application/json");
if let Some(token) = token {
builder = builder.header("authorization", format!("Bearer {token}"));
}
let request = builder.body(Body::from(body))?;
Ok(self
.router
.clone()
.oneshot(request)
.await
.expect("infallible response"))
}
pub async fn get(&self, path: &str, token: Option<&str>) -> Result<hyper::Response<Body>> {
let mut builder = Request::builder().method(Method::GET).uri(path);
if let Some(token) = token {
builder = builder.header("authorization", format!("Bearer {token}"));
}
let request = builder.body(Body::empty())?;
Ok(self
.router
.clone()
.oneshot(request)
.await
.expect("infallible response"))
}
#[allow(dead_code)]
pub async fn delete(&self, path: &str, token: Option<&str>) -> Result<hyper::Response<Body>> {
let builder = Request::builder().method(Method::DELETE).uri(path);
let builder = if let Some(token) = token {
builder.header("authorization", format!("Bearer {token}"))
} else {
builder
};
let request = builder.body(Body::empty())?;
Ok(self
.router
.clone()
.oneshot(request)
.await
.expect("infallible response"))
}
#[allow(dead_code)]
pub async fn upload_document(
&self,
path: &str,
filename: &str,
content_type: &str,
data: &[u8],
folder_id: Option<Uuid>,
token: &str,
) -> Result<hyper::Response<Body>> {
let extras = UploadExtras::empty();
self.upload_document_with_extras(
path,
filename,
content_type,
data,
folder_id,
extras,
token,
)
.await
}
pub async fn upload_document_with_options(
&self,
path: &str,
filename: &str,
content_type: &str,
data: &[u8],
folder_id: Option<Uuid>,
title: Option<&str>,
metadata_json: Option<&str>,
token: &str,
) -> Result<hyper::Response<Body>> {
let extras = UploadExtras {
title,
metadata_json,
tag_ids_json: None,
correspondents_json: None,
issued_at: None,
skip_existing: None,
};
self.upload_document_with_extras(
path,
filename,
content_type,
data,
folder_id,
extras,
token,
)
.await
}
pub async fn upload_document_with_extras(
&self,
path: &str,
filename: &str,
content_type: &str,
data: &[u8],
folder_id: Option<Uuid>,
extras: UploadExtras<'_>,
token: &str,
) -> Result<hyper::Response<Body>> {
let boundary = format!("boundary-{}", Uuid::new_v4());
let mut body = Vec::new();
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(
format!(
"Content-Disposition: form-data; name=\"file\"; filename=\"{}\"\r\n",
filename
)
.as_bytes(),
);
body.extend(format!("Content-Type: {}\r\n\r\n", content_type).as_bytes());
body.extend(data);
body.extend(b"\r\n");
if let Some(folder) = folder_id {
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(b"Content-Disposition: form-data; name=\"folder_id\"\r\n\r\n");
body.extend(folder.to_string().as_bytes());
body.extend(b"\r\n");
}
if let Some(title_value) = extras.title {
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(b"Content-Disposition: form-data; name=\"title\"\r\n\r\n");
body.extend(title_value.as_bytes());
body.extend(b"\r\n");
}
if let Some(metadata_value) = extras.metadata_json {
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(b"Content-Disposition: form-data; name=\"metadata\"\r\n\r\n");
body.extend(metadata_value.as_bytes());
body.extend(b"\r\n");
}
if let Some(tag_ids_value) = extras.tag_ids_json {
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(b"Content-Disposition: form-data; name=\"tag_ids\"\r\n\r\n");
body.extend(tag_ids_value.as_bytes());
body.extend(b"\r\n");
}
if let Some(correspondents_value) = extras.correspondents_json {
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(b"Content-Disposition: form-data; name=\"correspondents\"\r\n\r\n");
body.extend(correspondents_value.as_bytes());
body.extend(b"\r\n");
}
if let Some(issued_at_value) = extras.issued_at {
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(b"Content-Disposition: form-data; name=\"issued_at\"\r\n\r\n");
body.extend(issued_at_value.as_bytes());
body.extend(b"\r\n");
}
if let Some(skip_flag) = extras.skip_existing {
body.extend(format!("--{boundary}\r\n").as_bytes());
body.extend(b"Content-Disposition: form-data; name=\"skip_existing\"\r\n\r\n");
body.extend(if skip_flag {
b"true".as_ref()
} else {
b"false".as_ref()
});
body.extend(b"\r\n");
}
body.extend(format!("--{boundary}--\r\n").as_bytes());
let builder = Request::builder()
.method(Method::POST)
.uri(path)
.header(
"content-type",
format!("multipart/form-data; boundary={boundary}"),
)
.header("authorization", format!("Bearer {token}"));
let request = builder.body(Body::from(body))?;
Ok(self
.router
.clone()
.oneshot(request)
.await
.expect("infallible response"))
}
pub async fn with_conn<F, T>(&self, f: F) -> Result<T>
where
F: FnOnce(&mut PgConnection) -> Result<T> + Send + 'static,
T: Send + 'static,
{
let pool = self.state.pool.clone();
tokio::task::spawn_blocking(move || {
let mut conn = pool
.get()
.map_err(|err| anyhow!("failed to get database connection: {err}"))?;
f(&mut conn)
})
.await
.context("connection task panicked")?
}
}
pub struct UploadExtras<'a> {
pub title: Option<&'a str>,
pub metadata_json: Option<&'a str>,
pub tag_ids_json: Option<&'a str>,
pub correspondents_json: Option<&'a str>,
pub issued_at: Option<&'a str>,
pub skip_existing: Option<bool>,
}
impl<'a> UploadExtras<'a> {
pub fn empty() -> Self {
Self {
title: None,
metadata_json: None,
tag_ids_json: None,
correspondents_json: None,
issued_at: None,
skip_existing: None,
}
}
}
pub async fn acquire_db_lock() -> tokio::sync::MutexGuard<'static, ()> {
DB_LOCK.lock().await
}
pub async fn body_to_vec(body: Body) -> Result<Vec<u8>> {
let collected = body
.collect()
.await
.map_err(|err| anyhow!("failed to read response body: {err}"))?;
Ok(collected.to_bytes().to_vec())
}
#[cfg(test)]
mod helper_tests {
use super::*;
#[tokio::test]
async fn create_session_and_login_token_provide_access() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let username = "helper-login";
let password = "irrelevant";
app.insert_user(username, TestUserRole::Owner).await?;
let (access, refresh, refresh_id) = app.create_session(username).await?;
assert!(!access.is_empty(), "access token should not be empty");
assert!(!refresh.is_empty(), "refresh token should not be empty");
assert_ne!(
refresh_id,
Uuid::nil(),
"refresh token id should be assigned"
);
let bearer = app.login_token(username, password).await?;
assert!(!bearer.is_empty(), "login_token must yield bearer");
app.cleanup().await?;
Ok(())
}
#[tokio::test]
async fn insert_passkey_and_upload_with_options_succeeds() -> Result<()> {
let _lock = acquire_db_lock().await;
let app = TestApp::new().await?;
let username = "helper-passkey";
let password = "unused";
let user_id = app.insert_user(username, TestUserRole::Owner).await?;
let passkey_id = app.insert_passkey(user_id, Some("Laptop")).await?;
assert_ne!(passkey_id, Uuid::nil());
let bearer = app.login_token(username, password).await?;
let response = app
.upload_document_with_options(
"/api/documents",
"helper.txt",
"text/plain",
b"helper-content",
None,
Some("Helper Note"),
Some("{\"category\":\"note\"}"),
&bearer,
)
.await?;
assert!(response.status().is_success());
app.cleanup().await?;
Ok(())
}
}
async fn prepare_database(pool: &PgPool) -> Result<()> {
let pool = pool.clone();
tokio::task::spawn_blocking(move || -> Result<()> {
let mut conn = pool
.get()
.map_err(|err| anyhow!("failed to acquire connection: {err}"))?;
conn.batch_execute(RESET_DATABASE_SQL)
.map_err(|err| anyhow!("failed to reset schema: {err}"))?;
conn.batch_execute("DROP TABLE IF EXISTS __diesel_schema_migrations;")
.map_err(|err| anyhow!("failed to drop diesel schema table: {err}"))?;
conn.run_pending_migrations(MIGRATIONS)
.map_err(|err| anyhow!("failed to run migrations: {err}"))?;
truncate_all(&mut conn)?;
Ok(())
})
.await
.context("migration task panicked")?
}
fn truncate_all(conn: &mut PgConnection) -> Result<()> {
conn.batch_execute(
"TRUNCATE TABLE \
tenant.document_asset_objects, \
tenant.document_assets, \
tenant.document_correspondents, \
tenant.correspondents, \
tenant.document_tags, \
tenant.document_versions, \
tenant.documents, \
tenant.folders, \
shared.jobs, \
tenant.user_sessions, \
tenant.tags, \
tenant.api_tokens, \
shared.webauthn_challenges, \
shared.user_passkeys, \
tenant.user_memberships, \
shared.users, \
shared.magic_tokens, \
shared.tenants \
RESTART IDENTITY CASCADE;",
)
.context("failed to truncate tables")?;
Ok(())
}
fn generate_session_token() -> String {
let mut bytes = [0u8; 32];
OsRng.fill_bytes(&mut 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())
}
+1 -3
View File
@@ -1,7 +1,5 @@
mod common;
use anyhow::Result; use anyhow::Result;
use common::{acquire_db_lock, body_to_vec, TestApp, TestUserRole}; use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole};
use serde::Deserialize; use serde::Deserialize;
use serde_json::json; use serde_json::json;
use uuid::Uuid; use uuid::Uuid;
+3 -4
View File
@@ -1,9 +1,7 @@
mod common;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use axum::http::StatusCode; use axum::http::StatusCode;
use common::{acquire_db_lock, body_to_vec, TestApp, UploadExtras, TestUserRole};
use diesel::prelude::*; use diesel::prelude::*;
use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole, UploadExtras};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use serde_json::{json, Value}; use serde_json::{json, Value};
use uuid::Uuid; use uuid::Uuid;
@@ -1823,7 +1821,8 @@ async fn delete_document_requires_trash() -> Result<()> {
let app = TestApp::new().await?; let app = TestApp::new().await?;
let password = "conflict"; let password = "conflict";
app.insert_user("conflict-user", TestUserRole::Owner).await?; app.insert_user("conflict-user", TestUserRole::Owner)
.await?;
let token = app.login_token("conflict-user", password).await?; let token = app.login_token("conflict-user", password).await?;
let upload = app let upload = app
+1 -3
View File
@@ -1,8 +1,6 @@
mod common;
use anyhow::Result; use anyhow::Result;
use axum::http::StatusCode; use axum::http::StatusCode;
use common::{acquire_db_lock, body_to_vec, TestApp, TestUserRole}; use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole};
use serde::Deserialize; use serde::Deserialize;
#[derive(Deserialize)] #[derive(Deserialize)]
+1 -3
View File
@@ -1,8 +1,6 @@
mod common;
use anyhow::Result; use anyhow::Result;
use axum::http::StatusCode; use axum::http::StatusCode;
use common::{acquire_db_lock, body_to_vec, TestApp, TestUserRole}; use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole};
use serde::Deserialize; use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
use serde_json::json; use serde_json::json;
+1 -3
View File
@@ -1,8 +1,5 @@
mod common;
use anyhow::{anyhow, Result}; use anyhow::{anyhow, Result};
use axum::http::StatusCode; use axum::http::StatusCode;
use common::{acquire_db_lock, body_to_vec, TestApp, TestUserRole};
use diesel::prelude::*; use diesel::prelude::*;
use papercrate::auth::capability_sets::{ensure_capability_set, owner_capabilities}; use papercrate::auth::capability_sets::{ensure_capability_set, owner_capabilities};
use papercrate::models::{NewUser, NewUserMembership, Tag, TenantStatus}; use papercrate::models::{NewUser, NewUserMembership, Tag, TenantStatus};
@@ -10,6 +7,7 @@ use papercrate::schema::{
tags::dsl as tags_dsl, tenants::dsl as tenants_dsl, user_memberships::dsl as memberships_dsl, tags::dsl as tags_dsl, tenants::dsl as tenants_dsl, user_memberships::dsl as memberships_dsl,
users::dsl as users_dsl, users::dsl as users_dsl,
}; };
use papercrate::test_support::{acquire_db_lock, body_to_vec, TestApp, TestUserRole};
use serde::Deserialize; use serde::Deserialize;
use serde::Serialize; use serde::Serialize;
use uuid::Uuid; use uuid::Uuid;