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