This commit is contained in:
2025-11-05 22:15:27 +01:00
parent a75281aa13
commit c193b30c8e
10 changed files with 213 additions and 205 deletions
+122 -81
View File
@@ -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::{ApiCapability, ApiToken};
use papercrate::models::ApiToken;
use papercrate::routes::webdav;
use papercrate::schema::api_tokens;
use serde::Deserialize;
@@ -26,27 +26,8 @@ const LEGACY_WEBDAV_CAPS: &[&str] = &[
"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",
];
const READ_ONLY_CAPS: &[&str] = &["documents:read"];
const LIMITED_WEBDAV_CAPS: &[&str] = &["documents:read", "webdav:read"];
#[derive(Debug, Deserialize)]
struct TokenInfo {
@@ -54,7 +35,7 @@ struct TokenInfo {
label: Option<String>,
last_used_at: Option<String>,
revoked_at: Option<String>,
capabilities: Vec<ApiCapability>,
capability_set_id: Uuid,
}
#[derive(Debug, Deserialize)]
@@ -78,6 +59,12 @@ struct TenantView {
name: String,
}
#[derive(Debug, Deserialize)]
struct CapabilitySetSummary {
id: Uuid,
slug: String,
}
#[tokio::test]
async fn api_token_crud_flow() -> Result<()> {
let _guard = acquire_db_lock().await;
@@ -88,34 +75,30 @@ 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(
let legacy_set_id = ensure_capability_set_slug(
&app,
&access_token,
json!({ "label": "dav", "capabilities": LEGACY_WEBDAV_CAPS }),
"legacy_webdav",
LEGACY_WEBDAV_CAPS,
)
.await?;
let created =
create_token(&app, &access_token, Some("dav"), legacy_set_id, None).await?;
let token_id = created.info.id;
assert_eq!(created.info.label.as_deref(), Some("dav"));
assert!(created.info.last_used_at.is_none());
assert_capabilities(&created.info.capabilities, LEGACY_WEBDAV_CAPS);
assert_eq!(created.info.capability_set_id, legacy_set_id);
let regenerated = regenerate_token(&app, &access_token, token_id).await?;
assert_eq!(regenerated.info.id, token_id);
assert_ne!(regenerated.token, created.token);
assert!(regenerated.info.last_used_at.is_none());
let updated = update_token_capabilities(
&app,
&access_token,
token_id,
json!({ "capabilities": OWNER_CAPS }),
)
.await?;
assert_capabilities(&updated.capabilities, OWNER_CAPS);
let listed = list_tokens(&app, &access_token).await?;
assert_eq!(listed.len(), 1);
assert_eq!(listed[0].id, token_id);
assert_eq!(listed[0].capability_set_id, legacy_set_id);
let tenant_id_for_token = app
.with_conn(move |conn| {
@@ -127,6 +110,20 @@ async fn api_token_crud_flow() -> Result<()> {
})
.await?;
let readonly_set_id = ensure_capability_set_slug(
&app,
&access_token,
"readonly",
READ_ONLY_CAPS,
)
.await?;
let readonly_token =
create_token(&app, &access_token, Some("readonly"), readonly_set_id, None).await?;
let readonly_exchange = exchange_token(&app, &readonly_token.token).await?;
assert_eq!(readonly_exchange.tenant.id, tenant_id_for_token);
delete_token(&app, &access_token, readonly_token.info.id).await?;
let exchange = exchange_token(&app, &regenerated.token).await?;
assert_eq!(exchange.token_type, "Bearer");
assert!(!exchange.access_token.is_empty());
@@ -155,12 +152,16 @@ 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(
let legacy_set_id = ensure_capability_set_slug(
&app,
&access_token,
json!({ "label": "webdav", "capabilities": LEGACY_WEBDAV_CAPS }),
"legacy_webdav",
LEGACY_WEBDAV_CAPS,
)
.await?;
let created =
create_token(&app, &access_token, Some("webdav"), legacy_set_id, None).await?;
let token_id = created.info.id;
let router = webdav::create_router().with_state(app.state.clone());
@@ -229,16 +230,18 @@ async fn webdav_basic_auth_uses_api_tokens() -> Result<()> {
assert_eq!(response.status(), StatusCode::MULTI_STATUS);
// Token without webdav_read cannot authenticate.
let limited_token = create_token(
let read_only_set_id = ensure_capability_set_slug(
&app,
&access_token,
json!({
"label": "limited",
"capabilities": ["documents:read"]
}),
"documents_read",
READ_ONLY_CAPS,
)
.await?;
let limited_token =
create_token(&app, &access_token, Some("limited"), read_only_set_id, None).await?;
assert_eq!(limited_token.info.capability_set_id, read_only_set_id);
let limited_header = format!(
"Basic {}",
BASE64.encode(format!("{}:{}", username, limited_token.token))
@@ -253,20 +256,28 @@ async fn webdav_basic_auth_uses_api_tokens() -> Result<()> {
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 limited_set_id = ensure_capability_set_slug(
&app,
&access_token,
"documents_read_webdav",
LIMITED_WEBDAV_CAPS,
)
.await?;
let upgraded_token =
create_token(&app, &access_token, Some("limited-webdav"), limited_set_id, None).await?;
assert_eq!(upgraded_token.info.capability_set_id, limited_set_id);
let upgraded_request = Request::builder()
.method(propfind.clone())
.uri("/")
.header(header::AUTHORIZATION, limited_header)
.header(
header::AUTHORIZATION,
format!(
"Basic {}",
BASE64.encode(format!("{}:{}", username, upgraded_token.token))
),
)
.header("depth", "0")
.body(Body::empty())?;
let upgraded_response = router.clone().oneshot(upgraded_request).await?;
@@ -290,8 +301,22 @@ async fn webdav_basic_auth_uses_api_tokens() -> Result<()> {
async fn create_token(
app: &TestApp,
access_token: &str,
payload: serde_json::Value,
label: Option<&str>,
capability_set_id: Uuid,
expires_at: Option<&str>,
) -> Result<CreateTokenResponse> {
let mut payload = json!({
"capability_set_id": capability_set_id,
});
if let Some(label) = label {
payload["label"] = json!(label);
}
if let Some(expires) = expires_at {
payload["expires_at"] = json!(expires);
}
let response = app
.post_json("/api/profile/api-tokens", &payload, Some(access_token))
.await?;
@@ -317,24 +342,6 @@ async fn regenerate_token(
Ok(serde_json::from_slice(&body)?)
}
async fn update_token_capabilities(
app: &TestApp,
access_token: &str,
token_id: Uuid,
payload: serde_json::Value,
) -> Result<TokenInfo> {
let response = app
.patch_json(
&format!("/api/profile/api-tokens/{token_id}"),
&payload,
Some(access_token),
)
.await?;
assert_eq!(response.status(), StatusCode::OK);
let body = body_to_vec(response.into_body()).await?;
Ok(serde_json::from_slice(&body)?)
}
async fn list_tokens(app: &TestApp, access_token: &str) -> Result<Vec<TokenInfo>> {
let response = app
.get("/api/profile/api-tokens", Some(access_token))
@@ -355,17 +362,51 @@ async fn delete_token(app: &TestApp, access_token: &str, token_id: Uuid) -> Resu
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 ensure_capability_set_slug(
app: &TestApp,
access_token: &str,
slug: &str,
capabilities: &[&str],
) -> Result<Uuid> {
if let Some(existing) = find_capability_set_slug(app, access_token, slug).await? {
return Ok(existing);
}
let response = app
.post_json(
"/api/capability-sets",
&json!({
"slug": slug,
"capabilities": capabilities,
}),
Some(access_token),
)
.await?;
assert_eq!(response.status(), StatusCode::CREATED);
let body = body_to_vec(response.into_body()).await?;
let summary: CapabilitySetSummary = serde_json::from_slice(&body)?;
Ok(summary.id)
}
async fn find_capability_set_slug(
app: &TestApp,
access_token: &str,
slug: &str,
) -> Result<Option<Uuid>> {
let sets = list_capability_sets(app, access_token).await?;
Ok(sets.into_iter().find(|set| set.slug == slug).map(|set| set.id))
}
async fn list_capability_sets(
app: &TestApp,
access_token: &str,
) -> Result<Vec<CapabilitySetSummary>> {
let response = app
.get("/api/capability-sets", Some(access_token))
.await?;
assert_eq!(response.status(), StatusCode::OK);
let body = body_to_vec(response.into_body()).await?;
Ok(serde_json::from_slice(&body)?)
}
async fn exchange_token(app: &TestApp, api_token: &str) -> Result<LoginResponseView> {
let response = app