884 lines
27 KiB
Rust
884 lines
27 KiB
Rust
/*
|
|
* Copyright (C) 2026 Mateusz Gruszczyński @linuxiarz.pl
|
|
* Source-Available Code / Dual-Licensed.
|
|
*
|
|
* Free for non-commercial and evaluation use under terms of BSL/GPLv3.
|
|
* Commercial or production use requires a valid paid license.
|
|
* See LICENSE file in repository root for details.
|
|
*/
|
|
|
|
use super::*;
|
|
use sqlx::{Row, any::AnyRow};
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct FavoriteTarget {
|
|
kind: String,
|
|
slug: String,
|
|
#[serde(default)]
|
|
workspace_slug: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct FavoriteStatusQuery {
|
|
kind: String,
|
|
slug: String,
|
|
#[serde(default)]
|
|
workspace_slug: Option<String>,
|
|
}
|
|
|
|
#[derive(Debug, Deserialize)]
|
|
pub struct FavoriteListQuery {
|
|
#[serde(default)]
|
|
q: String,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct FavoriteStatus {
|
|
favorite: bool,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct FavoriteList {
|
|
items: Vec<FavoriteItem>,
|
|
}
|
|
|
|
#[derive(Debug, Serialize)]
|
|
pub struct FavoriteItem {
|
|
kind: String,
|
|
slug: String,
|
|
workspace_slug: Option<String>,
|
|
workspace_title: Option<String>,
|
|
title: String,
|
|
url: String,
|
|
updated_at: String,
|
|
favorited_at: String,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct FavoritePadRow {
|
|
slug: String,
|
|
title: String,
|
|
updated_at: String,
|
|
private_resource: i64,
|
|
protected_resource: i64,
|
|
favorited_at: String,
|
|
}
|
|
|
|
impl<'r> sqlx::FromRow<'r, AnyRow> for FavoritePadRow {
|
|
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
|
|
Ok(Self {
|
|
slug: crate::row_decode::text(row, "slug")?,
|
|
title: crate::row_decode::text(row, "title")?,
|
|
updated_at: crate::row_decode::text(row, "updated_at")?,
|
|
private_resource: row.try_get("private")?,
|
|
protected_resource: row.try_get("protected")?,
|
|
favorited_at: crate::row_decode::text(row, "favorited_at")?,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct FavoriteNoteRow {
|
|
slug: String,
|
|
title: String,
|
|
updated_at: String,
|
|
workspace_slug: String,
|
|
workspace_title: String,
|
|
private_resource: i64,
|
|
protected_resource: i64,
|
|
favorited_at: String,
|
|
}
|
|
|
|
impl<'r> sqlx::FromRow<'r, AnyRow> for FavoriteNoteRow {
|
|
fn from_row(row: &'r AnyRow) -> Result<Self, sqlx::Error> {
|
|
Ok(Self {
|
|
slug: crate::row_decode::text(row, "slug")?,
|
|
title: crate::row_decode::text(row, "title")?,
|
|
updated_at: crate::row_decode::text(row, "updated_at")?,
|
|
workspace_slug: crate::row_decode::text(row, "workspace_slug")?,
|
|
workspace_title: crate::row_decode::text(row, "workspace_title")?,
|
|
private_resource: row.try_get("private")?,
|
|
protected_resource: row.try_get("protected")?,
|
|
favorited_at: crate::row_decode::text(row, "favorited_at")?,
|
|
})
|
|
}
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
enum FavoriteResource {
|
|
Pad(i64),
|
|
Note(i64),
|
|
}
|
|
|
|
async fn favorite_user(
|
|
state: &SharedState,
|
|
headers: &HeaderMap,
|
|
) -> Result<crate::auth::User, ApiError> {
|
|
session_user(state, headers)
|
|
.await?
|
|
.ok_or_else(|| ApiError::forbidden("Log in to use favorites"))
|
|
}
|
|
|
|
async fn resolve_favorite_target(
|
|
state: &SharedState,
|
|
headers: &HeaderMap,
|
|
target: &FavoriteTarget,
|
|
) -> Result<FavoriteResource, ApiError> {
|
|
let kind = target.kind.trim();
|
|
let slug = target.slug.trim();
|
|
if slug.is_empty() {
|
|
return Err(ApiError::bad_request("Favorite slug is required"));
|
|
}
|
|
|
|
match kind {
|
|
"pad" => {
|
|
let pad = db::find_pad(&state.db, slug)
|
|
.await?
|
|
.ok_or_else(ApiError::not_found_note)?;
|
|
if (pad.is_private != 0 || pad.password_hash.is_some())
|
|
&& !has_header_resource_access(state, headers, "pad", &pad.slug).await?
|
|
{
|
|
return Err(ApiError::not_found_note());
|
|
}
|
|
Ok(FavoriteResource::Pad(pad.id))
|
|
}
|
|
"note" => {
|
|
let workspace_slug = target
|
|
.workspace_slug
|
|
.as_deref()
|
|
.map(str::trim)
|
|
.filter(|value| !value.is_empty())
|
|
.ok_or_else(|| ApiError::bad_request("Workspace slug is required for a note"))?;
|
|
let workspace = db::find_workspace(&state.db, workspace_slug)
|
|
.await?
|
|
.ok_or_else(ApiError::not_found_workspace)?;
|
|
if (workspace.is_private != 0 || workspace.password_hash.is_some())
|
|
&& !has_header_resource_access(state, headers, "workspace", &workspace.slug).await?
|
|
{
|
|
return Err(ApiError::not_found_workspace());
|
|
}
|
|
let note = db::find_note(&state.db, workspace.id, slug)
|
|
.await?
|
|
.ok_or_else(ApiError::not_found_note)?;
|
|
Ok(FavoriteResource::Note(note.id))
|
|
}
|
|
_ => Err(ApiError::bad_request("Invalid favorite resource kind")),
|
|
}
|
|
}
|
|
|
|
async fn favorite_exists(
|
|
state: &SharedState,
|
|
user_id: i64,
|
|
resource: FavoriteResource,
|
|
) -> Result<bool, ApiError> {
|
|
let (query, resource_id) = match resource {
|
|
FavoriteResource::Pad(id) => (queries::USER_FAVORITE_PAD_EXISTS, id),
|
|
FavoriteResource::Note(id) => (queries::USER_FAVORITE_NOTE_EXISTS, id),
|
|
};
|
|
let count: i64 = sqlx::query_scalar(queries::get(state.db.kind(), query))
|
|
.bind(user_id)
|
|
.bind(resource_id)
|
|
.fetch_one(state.db.pool())
|
|
.await?;
|
|
Ok(count > 0)
|
|
}
|
|
|
|
pub async fn favorite_status(
|
|
State(state): State<SharedState>,
|
|
headers: HeaderMap,
|
|
Query(query): Query<FavoriteStatusQuery>,
|
|
) -> Result<Json<FavoriteStatus>, ApiError> {
|
|
let user = favorite_user(&state, &headers).await?;
|
|
let target = FavoriteTarget {
|
|
kind: query.kind,
|
|
slug: query.slug,
|
|
workspace_slug: query.workspace_slug,
|
|
};
|
|
let resource = resolve_favorite_target(&state, &headers, &target).await?;
|
|
Ok(Json(FavoriteStatus {
|
|
favorite: favorite_exists(&state, user.id, resource).await?,
|
|
}))
|
|
}
|
|
|
|
pub async fn add_favorite(
|
|
State(state): State<SharedState>,
|
|
headers: HeaderMap,
|
|
Json(target): Json<FavoriteTarget>,
|
|
) -> Result<Json<FavoriteStatus>, ApiError> {
|
|
let user = favorite_user(&state, &headers).await?;
|
|
let resource = resolve_favorite_target(&state, &headers, &target).await?;
|
|
let (query, resource_id) = match resource {
|
|
FavoriteResource::Pad(id) => (queries::USER_FAVORITE_PAD_INSERT, id),
|
|
FavoriteResource::Note(id) => (queries::USER_FAVORITE_NOTE_INSERT, id),
|
|
};
|
|
sqlx::query(queries::get(state.db.kind(), query))
|
|
.bind(user.id)
|
|
.bind(resource_id)
|
|
.execute(state.db.pool())
|
|
.await?;
|
|
Ok(Json(FavoriteStatus { favorite: true }))
|
|
}
|
|
|
|
pub async fn remove_favorite(
|
|
State(state): State<SharedState>,
|
|
headers: HeaderMap,
|
|
Json(target): Json<FavoriteTarget>,
|
|
) -> Result<Json<FavoriteStatus>, ApiError> {
|
|
let user = favorite_user(&state, &headers).await?;
|
|
let resource = resolve_favorite_target(&state, &headers, &target).await?;
|
|
let (query, resource_id) = match resource {
|
|
FavoriteResource::Pad(id) => (queries::USER_FAVORITE_PAD_DELETE, id),
|
|
FavoriteResource::Note(id) => (queries::USER_FAVORITE_NOTE_DELETE, id),
|
|
};
|
|
sqlx::query(queries::get(state.db.kind(), query))
|
|
.bind(user.id)
|
|
.bind(resource_id)
|
|
.execute(state.db.pool())
|
|
.await?;
|
|
Ok(Json(FavoriteStatus { favorite: false }))
|
|
}
|
|
|
|
pub async fn list_favorites(
|
|
State(state): State<SharedState>,
|
|
headers: HeaderMap,
|
|
Query(query): Query<FavoriteListQuery>,
|
|
) -> Result<Json<FavoriteList>, ApiError> {
|
|
let user = favorite_user(&state, &headers).await?;
|
|
let pads = sqlx::query_as::<_, FavoritePadRow>(queries::get(
|
|
state.db.kind(),
|
|
queries::USER_FAVORITE_PAD_LIST,
|
|
))
|
|
.bind(user.id)
|
|
.fetch_all(state.db.pool())
|
|
.await?;
|
|
let notes = sqlx::query_as::<_, FavoriteNoteRow>(queries::get(
|
|
state.db.kind(),
|
|
queries::USER_FAVORITE_NOTE_LIST,
|
|
))
|
|
.bind(user.id)
|
|
.fetch_all(state.db.pool())
|
|
.await?;
|
|
|
|
let search = query.q.trim().to_lowercase();
|
|
let mut items = Vec::with_capacity(pads.len() + notes.len());
|
|
|
|
for pad in pads {
|
|
if (pad.private_resource != 0 || pad.protected_resource != 0)
|
|
&& !has_header_resource_access(&state, &headers, "pad", &pad.slug).await?
|
|
{
|
|
continue;
|
|
}
|
|
if !search.is_empty()
|
|
&& !pad.title.to_lowercase().contains(&search)
|
|
&& !pad.slug.to_lowercase().contains(&search)
|
|
{
|
|
continue;
|
|
}
|
|
items.push(FavoriteItem {
|
|
kind: "pad".into(),
|
|
url: format!("/p/{}", pad.slug),
|
|
slug: pad.slug,
|
|
workspace_slug: None,
|
|
workspace_title: None,
|
|
title: pad.title,
|
|
updated_at: db::normalize_timestamp(&pad.updated_at),
|
|
favorited_at: db::normalize_timestamp(&pad.favorited_at),
|
|
});
|
|
}
|
|
|
|
for note in notes {
|
|
if (note.private_resource != 0 || note.protected_resource != 0)
|
|
&& !has_header_resource_access(
|
|
&state,
|
|
&headers,
|
|
"workspace",
|
|
¬e.workspace_slug,
|
|
)
|
|
.await?
|
|
{
|
|
continue;
|
|
}
|
|
if !search.is_empty()
|
|
&& !note.title.to_lowercase().contains(&search)
|
|
&& !note.slug.to_lowercase().contains(&search)
|
|
&& !note.workspace_title.to_lowercase().contains(&search)
|
|
&& !note.workspace_slug.to_lowercase().contains(&search)
|
|
{
|
|
continue;
|
|
}
|
|
items.push(FavoriteItem {
|
|
kind: "note".into(),
|
|
url: format!("/w/{}/n/{}", note.workspace_slug, note.slug),
|
|
slug: note.slug,
|
|
workspace_slug: Some(note.workspace_slug),
|
|
workspace_title: Some(note.workspace_title),
|
|
title: note.title,
|
|
updated_at: db::normalize_timestamp(¬e.updated_at),
|
|
favorited_at: db::normalize_timestamp(¬e.favorited_at),
|
|
});
|
|
}
|
|
|
|
items.sort_by(|left, right| right.favorited_at.cmp(&left.favorited_at));
|
|
Ok(Json(FavoriteList { items }))
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::{database::Database, state::AppState, storage::Storage};
|
|
use sha2::{Digest, Sha256};
|
|
use std::sync::Arc;
|
|
|
|
async fn test_state() -> SharedState {
|
|
let db = Database::connect("sqlite::memory:", 1)
|
|
.await
|
|
.expect("test database");
|
|
crate::run_migrations(&db).await.expect("test migrations");
|
|
Arc::new(AppState::new(
|
|
db,
|
|
"test".into(),
|
|
Storage::Local {
|
|
root: std::env::temp_dir().join("rustpad-favorite-tests"),
|
|
},
|
|
1_000_000,
|
|
true,
|
|
1_000_000,
|
|
0,
|
|
None,
|
|
None,
|
|
true,
|
|
false,
|
|
false,
|
|
"error".into(),
|
|
7,
|
|
7,
|
|
7,
|
|
None,
|
|
))
|
|
}
|
|
|
|
async fn logged_in_headers(state: &SharedState, suffix: &str) -> (HeaderMap, i64) {
|
|
let nickname = format!("Favorite {suffix}");
|
|
let nickname_key = nickname.to_lowercase();
|
|
let email = format!("favorite-{suffix}@example.test");
|
|
sqlx::query(
|
|
"INSERT INTO users (nickname, nickname_key, email, email_key, password_hash) VALUES (?, ?, ?, ?, ?)",
|
|
)
|
|
.bind(&nickname)
|
|
.bind(&nickname_key)
|
|
.bind(&email)
|
|
.bind(&email)
|
|
.bind("password-hash")
|
|
.execute(state.db.pool())
|
|
.await
|
|
.unwrap();
|
|
let user_id: i64 = sqlx::query_scalar("SELECT id FROM users WHERE email_key = ?")
|
|
.bind(&email)
|
|
.fetch_one(state.db.pool())
|
|
.await
|
|
.unwrap();
|
|
let token = format!("favorite-session-{suffix}");
|
|
let expires_at = (Utc::now() + Duration::days(7)).to_rfc3339();
|
|
sqlx::query("INSERT INTO user_sessions (token, user_id, expires_at) VALUES (?, ?, ?)")
|
|
.bind(&token)
|
|
.bind(user_id)
|
|
.bind(expires_at)
|
|
.execute(state.db.pool())
|
|
.await
|
|
.unwrap();
|
|
let mut headers = HeaderMap::new();
|
|
headers.insert(
|
|
header::COOKIE,
|
|
HeaderValue::from_str(&format!(
|
|
"{}={token}",
|
|
crate::security::SESSION_COOKIE
|
|
))
|
|
.unwrap(),
|
|
);
|
|
(headers, user_id)
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn favorite_pad_is_idempotent_listed_and_deleted_with_pad() {
|
|
let state = test_state().await;
|
|
let (headers, user_id) = logged_in_headers(&state, "pad").await;
|
|
let pad = db::create_pad(&state.db, "favorite-pad", "Favorite Pad", None, None)
|
|
.await
|
|
.unwrap();
|
|
let target = || FavoriteTarget {
|
|
kind: "pad".into(),
|
|
slug: pad.slug.clone(),
|
|
workspace_slug: None,
|
|
};
|
|
|
|
for _ in 0..2 {
|
|
let result = add_favorite(
|
|
State(state.clone()),
|
|
headers.clone(),
|
|
Json(target()),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert!(result.0.favorite);
|
|
}
|
|
|
|
let stored: i64 = sqlx::query_scalar(
|
|
"SELECT COUNT(*) FROM user_pad_favorites WHERE user_id = ? AND pad_id = ?",
|
|
)
|
|
.bind(user_id)
|
|
.bind(pad.id)
|
|
.fetch_one(state.db.pool())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(stored, 1);
|
|
|
|
let listed = list_favorites(
|
|
State(state.clone()),
|
|
headers.clone(),
|
|
Query(FavoriteListQuery { q: String::new() }),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(listed.0.items.len(), 1);
|
|
assert_eq!(listed.0.items[0].kind, "pad");
|
|
assert_eq!(listed.0.items[0].url, "/p/favorite-pad");
|
|
|
|
sqlx::query("DELETE FROM pads WHERE id = ?")
|
|
.bind(pad.id)
|
|
.execute(state.db.pool())
|
|
.await
|
|
.unwrap();
|
|
let stored_after_delete: i64 =
|
|
sqlx::query_scalar("SELECT COUNT(*) FROM user_pad_favorites WHERE user_id = ?")
|
|
.bind(user_id)
|
|
.fetch_one(state.db.pool())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(stored_after_delete, 0);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn private_note_favorite_is_hidden_after_permission_revocation() {
|
|
let state = test_state().await;
|
|
let (headers, user_id) = logged_in_headers(&state, "private-note").await;
|
|
let workspace = db::create_workspace(
|
|
&state.db,
|
|
"private-workspace",
|
|
"Private Workspace",
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
sqlx::query("UPDATE workspaces SET is_private = 1 WHERE id = ?")
|
|
.bind(workspace.id)
|
|
.execute(state.db.pool())
|
|
.await
|
|
.unwrap();
|
|
let note = db::create_note(
|
|
&state.db,
|
|
workspace.id,
|
|
"shared-note",
|
|
"Shared Note",
|
|
false,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
sqlx::query(queries::get(
|
|
state.db.kind(),
|
|
queries::RESOURCE_PERMISSION_INSERT,
|
|
))
|
|
.bind("workspace")
|
|
.bind(&workspace.slug)
|
|
.bind(user_id)
|
|
.bind("ro")
|
|
.execute(state.db.pool())
|
|
.await
|
|
.unwrap();
|
|
|
|
add_favorite(
|
|
State(state.clone()),
|
|
headers.clone(),
|
|
Json(FavoriteTarget {
|
|
kind: "note".into(),
|
|
slug: note.slug.clone(),
|
|
workspace_slug: Some(workspace.slug.clone()),
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let before = list_favorites(
|
|
State(state.clone()),
|
|
headers.clone(),
|
|
Query(FavoriteListQuery { q: String::new() }),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(before.0.items.len(), 1);
|
|
|
|
sqlx::query(queries::get(
|
|
state.db.kind(),
|
|
queries::RESOURCE_PERMISSION_DELETE_USER,
|
|
))
|
|
.bind("workspace")
|
|
.bind(&workspace.slug)
|
|
.bind(user_id)
|
|
.execute(state.db.pool())
|
|
.await
|
|
.unwrap();
|
|
|
|
let after = list_favorites(
|
|
State(state.clone()),
|
|
headers.clone(),
|
|
Query(FavoriteListQuery { q: String::new() }),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert!(after.0.items.is_empty());
|
|
let stored: i64 = sqlx::query_scalar(
|
|
"SELECT COUNT(*) FROM user_note_favorites WHERE user_id = ? AND note_id = ?",
|
|
)
|
|
.bind(user_id)
|
|
.bind(note.id)
|
|
.fetch_one(state.db.pool())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(stored, 1);
|
|
|
|
sqlx::query(queries::get(
|
|
state.db.kind(),
|
|
queries::RESOURCE_PERMISSION_INSERT,
|
|
))
|
|
.bind("workspace")
|
|
.bind(&workspace.slug)
|
|
.bind(user_id)
|
|
.bind("ro")
|
|
.execute(state.db.pool())
|
|
.await
|
|
.unwrap();
|
|
let restored = list_favorites(
|
|
State(state.clone()),
|
|
headers,
|
|
Query(FavoriteListQuery { q: String::new() }),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(restored.0.items.len(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn public_note_favorite_hides_when_workspace_becomes_private_and_returns_when_public() {
|
|
let state = test_state().await;
|
|
let (headers, user_id) = logged_in_headers(&state, "privacy-toggle").await;
|
|
let workspace = db::create_workspace(
|
|
&state.db,
|
|
"privacy-toggle-workspace",
|
|
"Privacy Toggle Workspace",
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let note = db::create_note(
|
|
&state.db,
|
|
workspace.id,
|
|
"privacy-toggle-note",
|
|
"Privacy Toggle Note",
|
|
false,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
add_favorite(
|
|
State(state.clone()),
|
|
headers.clone(),
|
|
Json(FavoriteTarget {
|
|
kind: "note".into(),
|
|
slug: note.slug.clone(),
|
|
workspace_slug: Some(workspace.slug.clone()),
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
sqlx::query("UPDATE workspaces SET is_private = 1 WHERE id = ?")
|
|
.bind(workspace.id)
|
|
.execute(state.db.pool())
|
|
.await
|
|
.unwrap();
|
|
let hidden = list_favorites(
|
|
State(state.clone()),
|
|
headers.clone(),
|
|
Query(FavoriteListQuery { q: String::new() }),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert!(hidden.0.items.is_empty());
|
|
let stored: i64 = sqlx::query_scalar(
|
|
"SELECT COUNT(*) FROM user_note_favorites WHERE user_id = ? AND note_id = ?",
|
|
)
|
|
.bind(user_id)
|
|
.bind(note.id)
|
|
.fetch_one(state.db.pool())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(stored, 1);
|
|
|
|
sqlx::query("UPDATE workspaces SET is_private = 0 WHERE id = ?")
|
|
.bind(workspace.id)
|
|
.execute(state.db.pool())
|
|
.await
|
|
.unwrap();
|
|
let visible_again = list_favorites(
|
|
State(state),
|
|
headers,
|
|
Query(FavoriteListQuery { q: String::new() }),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(visible_again.0.items.len(), 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn revoked_share_link_hides_favorite_but_keeps_marker() {
|
|
let state = test_state().await;
|
|
let (mut headers, user_id) = logged_in_headers(&state, "share-revoke").await;
|
|
let workspace = db::create_workspace(
|
|
&state.db,
|
|
"share-revoke-workspace",
|
|
"Share Revoke Workspace",
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
sqlx::query("UPDATE workspaces SET is_private = 1 WHERE id = ?")
|
|
.bind(workspace.id)
|
|
.execute(state.db.pool())
|
|
.await
|
|
.unwrap();
|
|
let note = db::create_note(
|
|
&state.db,
|
|
workspace.id,
|
|
"share-revoke-note",
|
|
"Share Revoke Note",
|
|
false,
|
|
None,
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let share_token = "a".repeat(64);
|
|
let token_hash = hex::encode(Sha256::digest(share_token.as_bytes()));
|
|
sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_INSERT))
|
|
.bind(&token_hash)
|
|
.bind(Some("favorite test".to_owned()))
|
|
.bind("workspace")
|
|
.bind(&workspace.slug)
|
|
.bind("ro")
|
|
.bind(Option::<String>::None)
|
|
.bind(user_id)
|
|
.execute(state.db.pool())
|
|
.await
|
|
.unwrap();
|
|
headers.insert(
|
|
header::AUTHORIZATION,
|
|
HeaderValue::from_str(&format!("Bearer {share_token}")).unwrap(),
|
|
);
|
|
|
|
add_favorite(
|
|
State(state.clone()),
|
|
headers.clone(),
|
|
Json(FavoriteTarget {
|
|
kind: "note".into(),
|
|
slug: note.slug.clone(),
|
|
workspace_slug: Some(workspace.slug.clone()),
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let visible = list_favorites(
|
|
State(state.clone()),
|
|
headers.clone(),
|
|
Query(FavoriteListQuery { q: String::new() }),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(visible.0.items.len(), 1);
|
|
|
|
sqlx::query(queries::get(state.db.kind(), queries::SHARE_LINK_REVOKE))
|
|
.bind(Utc::now().to_rfc3339())
|
|
.bind(&token_hash)
|
|
.bind("workspace")
|
|
.bind(&workspace.slug)
|
|
.execute(state.db.pool())
|
|
.await
|
|
.unwrap();
|
|
let hidden = list_favorites(
|
|
State(state.clone()),
|
|
headers,
|
|
Query(FavoriteListQuery { q: String::new() }),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert!(hidden.0.items.is_empty());
|
|
let stored: i64 = sqlx::query_scalar(
|
|
"SELECT COUNT(*) FROM user_note_favorites WHERE user_id = ? AND note_id = ?",
|
|
)
|
|
.bind(user_id)
|
|
.bind(note.id)
|
|
.fetch_one(state.db.pool())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(stored, 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn revoked_password_token_hides_protected_pad_favorite() {
|
|
let state = test_state().await;
|
|
let (mut headers, user_id) = logged_in_headers(&state, "password-revoke").await;
|
|
let pad = db::create_pad(
|
|
&state.db,
|
|
"password-revoke-pad",
|
|
"Password Revoke Pad",
|
|
Some("password123"),
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let access_token = "b".repeat(64);
|
|
let token_hash = hex::encode(Sha256::digest(access_token.as_bytes()));
|
|
sqlx::query(queries::get(
|
|
state.db.kind(),
|
|
queries::RESOURCE_ACCESS_TOKENS_INSERT,
|
|
))
|
|
.bind(token_hash)
|
|
.bind("pad")
|
|
.bind(&pad.slug)
|
|
.bind((Utc::now() + Duration::days(7)).to_rfc3339())
|
|
.execute(state.db.pool())
|
|
.await
|
|
.unwrap();
|
|
headers.insert(
|
|
header::AUTHORIZATION,
|
|
HeaderValue::from_str(&format!("Bearer {access_token}")).unwrap(),
|
|
);
|
|
|
|
add_favorite(
|
|
State(state.clone()),
|
|
headers.clone(),
|
|
Json(FavoriteTarget {
|
|
kind: "pad".into(),
|
|
slug: pad.slug.clone(),
|
|
workspace_slug: None,
|
|
}),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
let visible = list_favorites(
|
|
State(state.clone()),
|
|
headers.clone(),
|
|
Query(FavoriteListQuery { q: String::new() }),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(visible.0.items.len(), 1);
|
|
|
|
sqlx::query(queries::get(
|
|
state.db.kind(),
|
|
queries::RESOURCE_ACCESS_TOKENS_DELETE_BY_RESOURCE,
|
|
))
|
|
.bind("pad")
|
|
.bind(&pad.slug)
|
|
.execute(state.db.pool())
|
|
.await
|
|
.unwrap();
|
|
let hidden = list_favorites(
|
|
State(state.clone()),
|
|
headers,
|
|
Query(FavoriteListQuery { q: String::new() }),
|
|
)
|
|
.await
|
|
.unwrap();
|
|
assert!(hidden.0.items.is_empty());
|
|
let stored: i64 = sqlx::query_scalar(
|
|
"SELECT COUNT(*) FROM user_pad_favorites WHERE user_id = ? AND pad_id = ?",
|
|
)
|
|
.bind(user_id)
|
|
.bind(pad.id)
|
|
.fetch_one(state.db.pool())
|
|
.await
|
|
.unwrap();
|
|
assert_eq!(stored, 1);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn favorites_require_an_active_login() {
|
|
let state = test_state().await;
|
|
db::create_pad(&state.db, "login-only-pad", "Login only", None, None)
|
|
.await
|
|
.unwrap();
|
|
let target = || FavoriteTarget {
|
|
kind: "pad".into(),
|
|
slug: "login-only-pad".into(),
|
|
workspace_slug: None,
|
|
};
|
|
|
|
let anonymous = add_favorite(
|
|
State(state.clone()),
|
|
HeaderMap::new(),
|
|
Json(target()),
|
|
)
|
|
.await
|
|
.expect_err("anonymous favorite must fail");
|
|
assert_eq!(anonymous.into_response().status(), StatusCode::FORBIDDEN);
|
|
|
|
let (inactive_headers, user_id) = logged_in_headers(&state, "inactive").await;
|
|
sqlx::query("UPDATE users SET is_active = 0 WHERE id = ?")
|
|
.bind(user_id)
|
|
.execute(state.db.pool())
|
|
.await
|
|
.unwrap();
|
|
let inactive = add_favorite(
|
|
State(state),
|
|
inactive_headers,
|
|
Json(target()),
|
|
)
|
|
.await
|
|
.expect_err("inactive account favorite must fail");
|
|
assert_eq!(inactive.into_response().status(), StatusCode::FORBIDDEN);
|
|
}
|
|
|
|
#[tokio::test]
|
|
async fn password_protected_pad_requires_current_resource_access() {
|
|
let state = test_state().await;
|
|
let (headers, _) = logged_in_headers(&state, "protected").await;
|
|
db::create_pad(
|
|
&state.db,
|
|
"protected-pad",
|
|
"Protected Pad",
|
|
Some("password123"),
|
|
None,
|
|
)
|
|
.await
|
|
.unwrap();
|
|
|
|
let error = add_favorite(
|
|
State(state),
|
|
headers,
|
|
Json(FavoriteTarget {
|
|
kind: "pad".into(),
|
|
slug: "protected-pad".into(),
|
|
workspace_slug: None,
|
|
}),
|
|
)
|
|
.await
|
|
.expect_err("protected favorite without resource access must fail");
|
|
assert_eq!(error.into_response().status(), StatusCode::NOT_FOUND);
|
|
}
|
|
}
|