update rust deps
This commit is contained in:
Generated
+575
-357
File diff suppressed because it is too large
Load Diff
+14
-14
@@ -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]]
|
||||||
|
|||||||
@@ -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,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,
|
||||||
|
|||||||
+66
-60
@@ -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,56 +52,59 @@ 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 {
|
||||||
if let Some(user) = parts.extensions.get::<AuthenticatedUser>() {
|
let state = state.clone();
|
||||||
return Ok(user.clone());
|
async move {
|
||||||
}
|
if let Some(user) = parts.extensions.get::<AuthenticatedUser>() {
|
||||||
|
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())?;
|
||||||
|
|
||||||
|
let claims = state
|
||||||
|
.jwt
|
||||||
|
.verify_token(bearer.token())
|
||||||
.map_err(|_| AppError::unauthorized())?;
|
.map_err(|_| AppError::unauthorized())?;
|
||||||
|
|
||||||
let claims = state
|
let mut tenant_conn = state.db_for_tenant(claims.tenant_id)?;
|
||||||
.jwt
|
let capability_set = load_capability_set(&mut tenant_conn, claims.capability_set_id)
|
||||||
.verify_token(bearer.token())
|
.map_err(|_| AppError::unauthorized())?;
|
||||||
.map_err(|_| AppError::unauthorized())?;
|
|
||||||
|
|
||||||
let mut tenant_conn = state.db_for_tenant(claims.tenant_id)?;
|
if capability_set.cap_version != claims.cap_version {
|
||||||
let capability_set = load_capability_set(&mut tenant_conn, claims.capability_set_id)
|
return Err(AppError::unauthorized());
|
||||||
.map_err(|_| AppError::unauthorized())?;
|
}
|
||||||
|
|
||||||
if capability_set.cap_version != claims.cap_version {
|
let capabilities = load_capabilities_for_set(&mut tenant_conn, capability_set.id)
|
||||||
return Err(AppError::unauthorized());
|
.map_err(|_| AppError::unauthorized())?;
|
||||||
|
|
||||||
|
let user = AuthenticatedUser {
|
||||||
|
user_id: claims.sub,
|
||||||
|
username: claims.username,
|
||||||
|
tenant_id: claims.tenant_id,
|
||||||
|
principal_kind: claims.principal_kind,
|
||||||
|
principal_id: claims.principal_id,
|
||||||
|
capability_set_id: claims.capability_set_id,
|
||||||
|
cap_version: claims.cap_version,
|
||||||
|
capabilities,
|
||||||
|
};
|
||||||
|
|
||||||
|
parts.extensions.insert(user.clone());
|
||||||
|
parts
|
||||||
|
.extensions
|
||||||
|
.insert(TenantConnectionHolder::new(tenant_conn));
|
||||||
|
|
||||||
|
Ok(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
let capabilities = load_capabilities_for_set(&mut tenant_conn, capability_set.id)
|
|
||||||
.map_err(|_| AppError::unauthorized())?;
|
|
||||||
|
|
||||||
let user = AuthenticatedUser {
|
|
||||||
user_id: claims.sub,
|
|
||||||
username: claims.username,
|
|
||||||
tenant_id: claims.tenant_id,
|
|
||||||
principal_kind: claims.principal_kind,
|
|
||||||
principal_id: claims.principal_id,
|
|
||||||
capability_set_id: claims.capability_set_id,
|
|
||||||
cap_version: claims.cap_version,
|
|
||||||
capabilities,
|
|
||||||
};
|
|
||||||
|
|
||||||
parts.extensions.insert(user.clone());
|
|
||||||
parts
|
|
||||||
.extensions
|
|
||||||
.insert(TenantConnectionHolder::new(tenant_conn));
|
|
||||||
|
|
||||||
Ok(user)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -118,29 +121,32 @@ 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();
|
||||||
let tenant_id = user.tenant_id;
|
async move {
|
||||||
let conn = if let Some(holder) = parts.extensions.remove::<TenantConnectionHolder>() {
|
let user = AuthenticatedUser::from_request_parts(parts, &state).await?;
|
||||||
holder
|
let tenant_id = user.tenant_id;
|
||||||
.into_conn()
|
let conn = if let Some(holder) = parts.extensions.remove::<TenantConnectionHolder>() {
|
||||||
.ok_or_else(|| AppError::internal("tenant connection unavailable"))?
|
holder
|
||||||
} else {
|
.into_conn()
|
||||||
state.db_for_tenant(tenant_id)?
|
.ok_or_else(|| AppError::internal("tenant connection unavailable"))?
|
||||||
};
|
} else {
|
||||||
|
state.db_for_tenant(tenant_id)?
|
||||||
|
};
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
conn,
|
conn,
|
||||||
tenant_id,
|
tenant_id,
|
||||||
user_id: user.user_id,
|
user_id: user.user_id,
|
||||||
user,
|
user,
|
||||||
})
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
])),
|
])),
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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
|
||||||
return Ok(existing);
|
.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 response = app
|
let caps_for_insert = desired_capabilities.clone();
|
||||||
.post_json(
|
app.with_conn(move |conn| {
|
||||||
"/api/capability-sets",
|
if let Some(existing) = capability_sets_dsl::capability_sets
|
||||||
&json!({
|
.filter(capability_sets_dsl::tenant_id.eq(tenant_id))
|
||||||
"slug": slug,
|
.filter(capability_sets_dsl::slug.eq(&slug))
|
||||||
"capabilities": capabilities,
|
.select(capability_sets_dsl::id)
|
||||||
}),
|
.first::<Uuid>(conn)
|
||||||
Some(access_token),
|
.optional()?
|
||||||
)
|
{
|
||||||
.await?;
|
return Ok(existing);
|
||||||
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(
|
let created =
|
||||||
app: &TestApp,
|
capability_sets::create_capability_set(conn, tenant_id, &slug, caps_for_insert)
|
||||||
access_token: &str,
|
.map_err(|err| {
|
||||||
slug: &str,
|
anyhow::anyhow!("failed to create capability set '{slug}': {err:?}")
|
||||||
) -> Result<Option<Uuid>> {
|
})?;
|
||||||
let sets = list_capability_sets(app, access_token).await?;
|
Ok(created.id)
|
||||||
Ok(sets
|
})
|
||||||
.into_iter()
|
.await
|
||||||
.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
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,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
@@ -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,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;
|
||||||
|
|||||||
@@ -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,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,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,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;
|
||||||
|
|||||||
Reference in New Issue
Block a user