use std::collections::HashMap;
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
use axum::Json;
use axum::extract::{Path, Query, State};
use axum::http::StatusCode;
use kellnr_appstate::{AppState, DbState, SettingsProvState, SettingsState};
use kellnr_auth::crate_access::{can_read_crate, crate_visibility};
use kellnr_auth::maybe_user::MaybeUser as MaybeUserOrToken;
use kellnr_common::crate_data::{CRATESIO_INDEX, CrateData};
use kellnr_common::crate_dependents::CrateDependents;
use kellnr_common::crate_overview::CrateOverview;
use kellnr_common::name_or_description::NameOrDescription;
use kellnr_common::normalized_name::NormalizedName;
use kellnr_common::original_name::OriginalName;
use kellnr_common::version::Version;
use kellnr_db::error::DbError;
use kellnr_db::{CrateSort, CrateTagFilter, DbProvider};
use kellnr_docs::upload::upload_dir_and_prune;
use kellnr_settings::{
ConfigSource, Provenance, Settings, SettingsProv, SourceMap, cli_flag_map, compile_time_config,
erased_serde, leaf_label, sources_from_prov,
};
use serde::{Deserialize, Serialize};
use tracing::error;
use utoipa::ToSchema;
use crate::error::RouteError;
use crate::session::{AdminUser, MaybeUser};
#[derive(Serialize, Deserialize)]
pub struct SettingsResponse {
#[serde(flatten)]
pub settings: serde_json::Map<String, serde_json::Value>,
pub sources: SourceMap,
pub defaults: serde_json::Value,
pub leaves: Vec<LeafMeta>,
}
pub const REDACTED: &str = "********";
#[derive(Serialize, Deserialize, Clone, Debug)]
pub struct LeafMeta {
pub key: String,
pub value: serde_json::Value,
pub default: serde_json::Value,
pub source: ConfigSource,
#[serde(rename = "type")]
pub kind: LeafKind,
pub secret: bool,
pub cli_flag: Option<String>,
pub label: Option<String>,
}
#[derive(Serialize, Deserialize, Clone, Copy, Debug, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum LeafKind {
Boolean,
Number,
String,
Array,
}
fn build_leaves(prov: &SettingsProv) -> Vec<LeafMeta> {
let defaults = Settings::default();
let defaults_json =
serde_json::to_value(&defaults).expect("Settings serializes as JSON for the defaults map");
let flag_map = cli_flag_map();
let mut leaves: Vec<LeafMeta> = Vec::new();
prov.walk_leaves("", &mut |path, value, category, secret| {
let Ok(value_json) = json_from_erased(value) else {
return;
};
let mut leaf_default = lookup_default(&defaults_json, path);
let mut value_json = value_json;
if secret {
value_json = redact_secret(&value_json);
leaf_default = redact_secret(&leaf_default);
}
let kind = LeafKind::from_json(&value_json);
leaves.push(LeafMeta {
key: path.to_string(),
value: value_json,
default: leaf_default,
source: category.into(),
kind,
secret,
cli_flag: flag_map.get(path).cloned(),
label: leaf_label(path).map(String::from),
});
});
leaves.sort_by(|a, b| a.key.cmp(&b.key));
leaves
}
fn redact_secret(value: &serde_json::Value) -> serde_json::Value {
match value {
serde_json::Value::Null => serde_json::Value::Null,
serde_json::Value::String(s) if s.is_empty() => serde_json::Value::Null,
_ => serde_json::Value::String(REDACTED.to_string()),
}
}
fn redacted_settings_json(
settings: &Settings,
leaves: &[LeafMeta],
) -> Result<serde_json::Value, serde_json::Error> {
let mut json = serde_json::to_value(settings)?;
for leaf in leaves.iter().filter(|leaf| leaf.secret) {
let pointer = format!("/{}", leaf.key.replace('.', "/"));
if let Some(value) = json.pointer_mut(&pointer) {
*value = redact_secret(value);
}
}
Ok(json)
}
fn json_from_erased(
value: &dyn erased_serde::Serialize,
) -> Result<serde_json::Value, serde_json::Error> {
struct Erased<'a>(&'a dyn erased_serde::Serialize);
impl Serialize for Erased<'_> {
fn serialize<S: serde::Serializer>(&self, ser: S) -> Result<S::Ok, S::Error> {
erased_serde::serialize(self.0, ser)
}
}
serde_json::to_value(Erased(value))
}
fn lookup_default(defaults_json: &serde_json::Value, dotted_path: &str) -> serde_json::Value {
let mut current = defaults_json;
for segment in dotted_path.split('.') {
let Some(next) = current.get(segment) else {
return serde_json::Value::Null;
};
current = next;
}
current.clone()
}
impl LeafKind {
fn from_json(value: &serde_json::Value) -> Self {
match value {
serde_json::Value::Bool(_) => Self::Boolean,
serde_json::Value::Number(_) => Self::Number,
serde_json::Value::Array(_) => Self::Array,
_ => Self::String,
}
}
}
#[utoipa::path(
get,
path = "/settings",
tag = "ui",
responses(
(status = 200, description = "Kellnr settings with source tracking"),
(status = 403, description = "Admin access required")
),
security(("session_cookie" = []))
)]
#[allow(clippy::unused_async)] pub async fn settings(
_user: AdminUser,
State(settings): SettingsState,
State(prov): SettingsProvState,
) -> Result<Json<SettingsResponse>, RouteError> {
let leaves = build_leaves(&prov);
let serialization_error = |e: serde_json::Error| {
error!("Failed to serialize settings: {e}");
RouteError::Status(StatusCode::INTERNAL_SERVER_ERROR)
};
let serde_json::Value::Object(settings) =
redacted_settings_json(&settings, &leaves).map_err(serialization_error)?
else {
return Err(RouteError::Status(StatusCode::INTERNAL_SERVER_ERROR));
};
let defaults =
redacted_settings_json(&Settings::default(), &leaves).map_err(serialization_error)?;
Ok(Json(SettingsResponse {
sources: sources_from_prov(&prov),
settings,
defaults,
leaves,
}))
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, ToSchema)]
pub struct DocsEnabledResponse {
pub enabled: bool,
}
#[utoipa::path(
get,
path = "/docs_enabled",
tag = "ui",
responses(
(status = 200, description = "Documentation generation status", body = DocsEnabledResponse)
)
)]
#[allow(clippy::unused_async)] pub async fn docs_enabled(State(settings): SettingsState) -> Json<DocsEnabledResponse> {
Json(DocsEnabledResponse {
enabled: settings.docs.enabled,
})
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, ToSchema)]
pub struct SponsorLinksEnabledResponse {
pub enabled: bool,
}
#[utoipa::path(
get,
path = "/sponsor_links_enabled",
tag = "ui",
responses(
(status = 200, description = "Sponsor links status", body = SponsorLinksEnabledResponse)
)
)]
#[allow(clippy::unused_async)] pub async fn sponsor_links_enabled(
State(settings): SettingsState,
) -> Json<SponsorLinksEnabledResponse> {
Json(SponsorLinksEnabledResponse {
enabled: settings.registry.sponsor_links_enabled,
})
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, ToSchema)]
pub struct KellnrVersion {
pub version: String,
}
#[utoipa::path(
get,
path = "/version",
tag = "ui",
responses(
(status = 200, description = "Kellnr version", body = KellnrVersion)
)
)]
#[allow(clippy::unused_async)] pub async fn kellnr_version() -> Json<KellnrVersion> {
Json(KellnrVersion {
version: compile_time_config::KELLNR_COMPTIME__VERSION.to_string(),
})
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, ToSchema, utoipa::IntoParams)]
pub struct CratesParams {
page: Option<u64>,
page_size: Option<u64>,
cache: Option<bool>,
sort: Option<CrateSort>,
keyword: Option<String>,
category: Option<String>,
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, ToSchema)]
pub struct Pagination {
crates: Vec<CrateOverview>,
page_size: u64,
page: u64,
total: u64,
}
#[utoipa::path(
get,
path = "/crates",
tag = "ui",
params(CratesParams),
responses(
(status = 200, description = "Paginated crate list", body = Pagination)
)
)]
pub async fn crates(
Query(params): Query<CratesParams>,
State(db): DbState,
user: Result<MaybeUserOrToken, StatusCode>,
) -> Json<Pagination> {
let page_size = params.page_size.unwrap_or(10);
let page = params.page.unwrap_or(0);
let cache = params.cache.unwrap_or(false);
let visibility = crate_visibility(user.ok().as_ref());
let sort = params.sort.unwrap_or_default();
let tags = CrateTagFilter {
keyword: params.keyword.filter(|k| !k.is_empty()),
category: params.category.filter(|c| !c.is_empty()),
};
let crates = db
.get_crate_overview_list(page_size, page_size * page, cache, &visibility, sort, &tags)
.await
.unwrap_or_default();
let total = db
.count_crate_overview_list(cache, &visibility, &tags)
.await
.unwrap_or_default();
Json(Pagination {
crates,
page_size,
page,
total,
})
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, ToSchema, utoipa::IntoParams)]
pub struct SearchParams {
name: NameOrDescription,
page: Option<u64>,
page_size: Option<u64>,
cache: Option<bool>,
}
#[utoipa::path(
get,
path = "/search",
tag = "ui",
params(SearchParams),
responses(
(status = 200, description = "Paginated search results", body = Pagination)
)
)]
pub async fn search(
Query(params): Query<SearchParams>,
State(db): DbState,
user: Result<MaybeUserOrToken, StatusCode>,
) -> Json<Pagination> {
let page_size = params.page_size.unwrap_or(10);
let page = params.page.unwrap_or(0);
let cache = params.cache.unwrap_or(false);
let visibility = crate_visibility(user.ok().as_ref());
let crates = db
.search_in_crate_name_and_description(
params.name.as_str(),
page_size,
page_size * page,
cache,
&visibility,
)
.await
.unwrap_or_default();
let total = db
.count_by_crate_name_and_description(params.name.as_str(), cache, &visibility)
.await
.unwrap_or_default();
Json(Pagination {
crates,
page_size,
page,
total,
})
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, ToSchema, utoipa::IntoParams)]
pub struct CrateDataParams {
name: OriginalName,
}
#[utoipa::path(
get,
path = "/crate_data",
tag = "ui",
params(CrateDataParams),
responses(
(status = 200, description = "Crate details", body = CrateData),
(status = 404, description = "Crate not found or download-restricted without access")
)
)]
pub async fn crate_data(
Query(params): Query<CrateDataParams>,
State(db): DbState,
user: Result<MaybeUserOrToken, StatusCode>,
) -> Result<Json<CrateData>, StatusCode> {
let index_name = NormalizedName::from(params.name);
let user = user.ok();
match can_read_crate(&index_name, user.as_ref(), &db).await {
Ok(true) => {}
Ok(false) => return Err(StatusCode::NOT_FOUND),
Err(e) => {
error!("Failed to check access to crate {index_name}: {e}");
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
}
let mut cd = db.get_crate_data(&index_name).await.map_err(|e| match e {
DbError::CrateNotFound(_) => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR,
})?;
hide_restricted_dep_info(&mut cd, user.as_ref(), &db)
.await
.map_err(|e| {
error!("Failed to check access to the dependencies of {index_name}: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(cd))
}
async fn hide_restricted_dep_info(
cd: &mut CrateData,
user: Option<&MaybeUserOrToken>,
db: &Arc<dyn DbProvider>,
) -> Result<(), DbError> {
let mut readable: HashMap<String, bool> = HashMap::new();
for dep in cd
.versions
.iter_mut()
.flat_map(|v| v.dependencies.iter_mut())
{
if dep.registry.as_deref() == Some(CRATESIO_INDEX) {
continue;
}
let name = NormalizedName::from_unchecked(dep.name.to_lowercase());
let can_read = if let Some(can_read) = readable.get(name.as_str()) {
*can_read
} else {
let can_read = can_read_crate(&name, user, db).await?;
readable.insert(name.to_string(), can_read);
can_read
};
if !can_read {
dep.description = None;
dep.latest_version = None;
dep.outdated = false;
}
}
Ok(())
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, ToSchema, utoipa::IntoParams)]
pub struct CrateDependentsParams {
name: OriginalName,
}
#[utoipa::path(
get,
path = "/crate_dependents",
tag = "ui",
params(CrateDependentsParams),
responses(
(status = 200, description = "Dependents of the crate", body = CrateDependents),
(status = 404, description = "Crate not found or download-restricted without access")
)
)]
pub async fn crate_dependents(
Query(params): Query<CrateDependentsParams>,
State(db): DbState,
user: Result<MaybeUserOrToken, StatusCode>,
) -> Result<Json<CrateDependents>, StatusCode> {
let index_name = NormalizedName::from(params.name);
let user = user.ok();
match can_read_crate(&index_name, user.as_ref(), &db).await {
Ok(true) => {}
Ok(false) => return Err(StatusCode::NOT_FOUND),
Err(e) => {
error!("Failed to check access to crate {index_name}: {e}");
return Err(StatusCode::INTERNAL_SERVER_ERROR);
}
}
let mut cd = db
.get_crate_dependents(&index_name)
.await
.map_err(|e| match e {
DbError::CrateNotFound(_) => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR,
})?;
hide_restricted_dependents(&mut cd, user.as_ref(), &db)
.await
.map_err(|e| {
error!("Failed to check access to the dependents of {index_name}: {e}");
StatusCode::INTERNAL_SERVER_ERROR
})?;
Ok(Json(cd))
}
async fn hide_restricted_dependents(
cd: &mut CrateDependents,
user: Option<&MaybeUserOrToken>,
db: &Arc<dyn DbProvider>,
) -> Result<(), DbError> {
let mut visible = Vec::with_capacity(cd.dependents.len());
for dependent in std::mem::take(&mut cd.dependents) {
let name = NormalizedName::from_unchecked(dependent.name.to_lowercase());
if can_read_crate(&name, user, db).await? {
visible.push(dependent);
} else {
cd.restricted += 1;
}
}
cd.dependents = visible;
Ok(())
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, ToSchema, utoipa::IntoParams)]
pub struct CratesIoDataParams {
name: OriginalName,
}
#[utoipa::path(
get,
path = "/cratesio_data",
tag = "ui",
params(CratesIoDataParams),
responses(
(status = 200, description = "Crates.io crate data", body = String),
(status = 404, description = "Crate not found")
)
)]
pub async fn cratesio_data(
State(settings): SettingsState,
Query(params): Query<CratesIoDataParams>,
) -> Result<String, StatusCode> {
let url = settings
.proxy
.api
.join(¶ms.name)
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let client = kellnr_common::cratesio_downloader::build_client(
&settings.proxy.user_agent,
Duration::from_secs(settings.proxy.connect_timeout_seconds),
Duration::from_secs(settings.proxy.request_timeout_seconds),
);
let req = client.get(url).header("Accept", "application/json");
let resp = req.send().await;
match resp {
Ok(resp) => match resp.status() {
StatusCode::OK => {
let data = resp.text().await;
match data {
Ok(data) => Ok(data),
Err(e) => {
error!("Failed to parse crates.io data: {e}");
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
StatusCode::NOT_FOUND => Err(StatusCode::NOT_FOUND),
_ => {
error!("Failed to get crates.io data: {}", resp.status());
Err(StatusCode::NOT_FOUND)
}
},
Err(e) => {
error!("Failed to get crates.io data: {e}");
Err(StatusCode::INTERNAL_SERVER_ERROR)
}
}
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, ToSchema)]
pub struct DeleteCrateVersionParams {
name: OriginalName,
version: Version,
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, ToSchema)]
pub struct DeleteCrateParams {
name: OriginalName,
}
async fn delete_crate_versions_impl(
state: &kellnr_appstate::AppStateData,
name: &OriginalName,
versions: Option<Vec<Version>>,
) -> Result<(), RouteError> {
let versions_to_delete = if let Some(v) = versions {
v
} else {
let crate_meta = state.db.get_crate_meta_list(&name.to_normalized()).await?;
crate_meta
.iter()
.map(|cm| Version::from_unchecked_str(&cm.version))
.collect()
};
for version in &versions_to_delete {
if let Err(e) = state.db.delete_crate(&name.to_normalized(), version).await {
error!("Failed to delete crate from database: {e:?}");
return Err(RouteError::Status(StatusCode::INTERNAL_SERVER_ERROR));
}
state
.download_filters
.registry
.licenses()
.forget(&name.to_normalized(), version);
if let Err(e) = state.crate_storage.delete(name, version).await {
error!("Failed to delete crate from storage: {e}");
return Err(RouteError::Status(StatusCode::INTERNAL_SERVER_ERROR));
}
if let Err(e) = kellnr_docs::delete(name, version, &state.docs_storage).await {
error!("Failed to delete crate from docs: {e}");
return Err(RouteError::Status(StatusCode::INTERNAL_SERVER_ERROR));
}
}
Ok(())
}
pub async fn delete_version(
Query(params): Query<DeleteCrateVersionParams>,
_user: AdminUser,
State(state): AppState,
) -> Result<(), RouteError> {
delete_crate_versions_impl(&state, ¶ms.name, Some(vec![params.version])).await
}
pub async fn delete_crate(
Query(params): Query<DeleteCrateParams>,
_user: AdminUser,
State(state): AppState,
) -> Result<(), RouteError> {
delete_crate_versions_impl(&state, ¶ms.name, None).await
}
#[utoipa::path(
delete,
path = "/crates/{name}/{version}",
tag = "ui",
params(
("name" = String, Path, description = "Crate name"),
("version" = String, Path, description = "Version to delete")
),
responses(
(status = 200, description = "Crate version deleted"),
(status = 400, description = "Invalid parameters"),
(status = 403, description = "Admin access required")
),
security(("session_cookie" = []))
)]
pub async fn delete_crate_version(
Path((name, version)): Path<(String, String)>,
_user: AdminUser,
State(state): AppState,
) -> Result<(), RouteError> {
let name = OriginalName::try_from(name.as_str())
.map_err(|_| RouteError::Status(StatusCode::BAD_REQUEST))?;
let version = Version::try_from(version.as_str())
.map_err(|_| RouteError::Status(StatusCode::BAD_REQUEST))?;
delete_crate_versions_impl(&state, &name, Some(vec![version])).await
}
#[utoipa::path(
delete,
path = "/crates/{name}",
tag = "ui",
params(
("name" = String, Path, description = "Crate name")
),
responses(
(status = 200, description = "All crate versions deleted"),
(status = 400, description = "Invalid parameters"),
(status = 403, description = "Admin access required")
),
security(("session_cookie" = []))
)]
pub async fn delete_crate_all(
Path(name): Path<String>,
_user: AdminUser,
State(state): AppState,
) -> Result<(), RouteError> {
let name = OriginalName::try_from(name.as_str())
.map_err(|_| RouteError::Status(StatusCode::BAD_REQUEST))?;
delete_crate_versions_impl(&state, &name, None).await
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, ToSchema)]
pub struct Statistic {
pub num_crates: u64,
pub num_crate_versions: u64,
pub num_crate_downloads: u64,
pub num_proxy_crates: u64,
pub num_proxy_crate_versions: u64,
pub num_proxy_crate_downloads: u64,
pub top_crates: TopCrates,
pub last_updated_crate: Option<(OriginalName, Version)>,
pub proxy_enabled: bool,
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, PartialEq, ToSchema)]
pub struct TopCrates {
pub first: (String, u64),
pub second: (String, u64),
pub third: (String, u64),
}
#[utoipa::path(
get,
path = "/statistics",
tag = "ui",
responses(
(status = 200, description = "Registry statistics", body = Statistic)
)
)]
pub async fn statistic(
State(db): DbState,
State(settings): SettingsState,
user: Result<MaybeUserOrToken, StatusCode>,
) -> Json<Statistic> {
fn extract(tops: &[(String, u64)], i: usize) -> (String, u64) {
if tops.len() > i {
tops[i].clone()
} else {
(String::new(), 0)
}
}
let num_crates = db.get_total_unique_crates().await.unwrap_or_default();
let num_crate_versions = db.get_total_crate_versions().await.unwrap_or_default();
let num_crate_downloads = db.get_total_downloads().await.unwrap_or_default();
let visibility = crate_visibility(user.ok().as_ref());
let tops = db
.get_top_crates_downloads(3, &visibility)
.await
.unwrap_or_default();
let num_proxy_crates = db
.get_total_unique_cached_crates()
.await
.unwrap_or_default();
let num_proxy_crate_versions = db
.get_total_cached_crate_versions()
.await
.unwrap_or_default();
let num_proxy_crate_downloads = db.get_total_cached_downloads().await.unwrap_or_default();
let last_updated_crate = db
.get_last_updated_crate(&visibility)
.await
.unwrap_or_default();
Json(Statistic {
num_crates,
num_crate_versions,
num_crate_downloads,
num_proxy_crates,
num_proxy_crate_versions,
num_proxy_crate_downloads,
top_crates: TopCrates {
first: extract(&tops, 0),
second: extract(&tops, 1),
third: extract(&tops, 2),
},
last_updated_crate,
proxy_enabled: settings.proxy.enabled,
})
}
#[derive(serde::Serialize, serde::Deserialize, Debug, Clone, ToSchema, utoipa::IntoParams)]
pub struct BuildParams {
package: OriginalName,
version: Version,
}
#[utoipa::path(
post,
path = "/builds",
tag = "docs",
params(BuildParams),
responses(
(status = 200, description = "Build queued successfully"),
(status = 400, description = "Crate or version does not exist"),
(status = 401, description = "Not authorized, not an owner or read-only")
),
security(("session_cookie" = []))
)]
pub async fn build_rustdoc(
Query(params): Query<BuildParams>,
State(state): AppState,
user: MaybeUser,
) -> Result<(), StatusCode> {
if !state.settings.docs.enabled {
return Err(StatusCode::BAD_REQUEST);
}
let normalized_name = NormalizedName::from(params.package);
let db = state.db;
let version = params.version;
let is_allowed = match user {
MaybeUser::Normal(user) => {
db.is_owner(&normalized_name, &user)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
&& !db
.get_user(&user)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.is_read_only
}
MaybeUser::Admin(_) => true,
};
if !is_allowed {
return Err(StatusCode::UNAUTHORIZED);
}
if let Some(id) = db
.get_crate_id(&normalized_name)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
{
if !db
.crate_version_exists(id, &version)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
{
return Err(StatusCode::BAD_REQUEST);
}
} else {
return Err(StatusCode::BAD_REQUEST);
}
db.add_doc_queue(
&normalized_name,
&version,
&state
.crate_storage
.create_rand_doc_queue_path()
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?,
)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(())
}
#[derive(Serialize, Deserialize, ToSchema)]
pub struct MigrateDocsStorageResponse {
pub started: bool,
pub crate_versions_found: usize,
}
#[utoipa::path(
post,
path = "/migrate-storage",
tag = "docs",
responses(
(status = 200, description = "No local docs found to migrate", body = MigrateDocsStorageResponse),
(status = 202, description = "Migration started in the background", body = MigrateDocsStorageResponse),
(status = 400, description = "Docs are still backed by local disk, nothing to migrate to"),
(status = 401, description = "Not authorized"),
(status = 403, description = "Not an admin")
),
security(("session_cookie" = []))
)]
pub async fn migrate_docs_storage(
State(state): AppState,
_admin: AdminUser,
) -> Result<(StatusCode, Json<MigrateDocsStorageResponse>), RouteError> {
if state.settings.docs_backend_is_local() {
return Err(RouteError::Status(StatusCode::BAD_REQUEST));
}
let docs_path = PathBuf::from(state.settings.docs_path());
let crate_versions = match list_local_crate_versions(&docs_path).await {
Ok(crate_versions) => crate_versions,
Err(e) if e.kind() == std::io::ErrorKind::NotFound => Vec::new(),
Err(e) => {
error!("Failed to read local docs directory {docs_path:?}: {e}");
return Err(RouteError::Status(StatusCode::INTERNAL_SERVER_ERROR));
}
};
if crate_versions.is_empty() {
return Ok((
StatusCode::OK,
Json(MigrateDocsStorageResponse {
started: false,
crate_versions_found: 0,
}),
));
}
let crate_versions_found = crate_versions.len();
let docs_storage = state.docs_storage.clone();
tokio::spawn(async move {
for (crate_name, version, dir) in crate_versions {
match upload_dir_and_prune(&dir, "", &crate_name, &version, &docs_storage).await {
Ok(()) => {
tracing::info!(
"Migrated local docs for {crate_name} {version} to configured storage backend"
);
}
Err(e) => {
error!(
"Failed to migrate local docs for {crate_name} {version} to configured storage backend: {e}"
);
}
}
}
tracing::info!(
"Finished docs storage migration for {crate_versions_found} crate/version(s)"
);
});
Ok((
StatusCode::ACCEPTED,
Json(MigrateDocsStorageResponse {
started: true,
crate_versions_found,
}),
))
}
async fn list_local_crate_versions(
docs_path: &std::path::Path,
) -> std::io::Result<Vec<(String, String, PathBuf)>> {
let mut result = Vec::new();
let mut crate_dirs = tokio::fs::read_dir(docs_path).await?;
while let Some(crate_entry) = crate_dirs.next_entry().await? {
if !crate_entry.file_type().await?.is_dir() {
continue;
}
let crate_name = crate_entry.file_name().to_string_lossy().into_owned();
let mut version_dirs = tokio::fs::read_dir(crate_entry.path()).await?;
while let Some(version_entry) = version_dirs.next_entry().await? {
if !version_entry.file_type().await?.is_dir() {
continue;
}
let version = version_entry.file_name().to_string_lossy().into_owned();
result.push((crate_name.clone(), version, version_entry.path()));
}
}
Ok(result)
}
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use std::sync::Arc;
use axum::Router;
use axum::body::Body;
use axum::routing::{get, post};
use axum_extra::extract::cookie::Key;
use bytes::Bytes;
use http_body_util::BodyExt;
use hyper::{Request, header};
use kellnr_appstate::{AppStateData, encode_cookies};
use kellnr_common::crate_data::{CrateRegistryDep, CrateVersionData};
use kellnr_common::crate_dependents::CrateDependent;
use kellnr_db::error::DbError;
use kellnr_db::mock::MockDb;
use kellnr_db::{CrateVisibility, User};
use kellnr_settings::{Postgresql, Settings, Setup, constants};
use kellnr_storage::cached_crate_storage::DynStorage;
use kellnr_storage::docs_storage::DocsStorage;
use kellnr_storage::fs_storage::FSStorage;
use kellnr_storage::kellnr_crate_storage::KellnrCrateStorage;
use mockall::predicate::*;
use tower::ServiceExt;
use super::*;
#[tokio::test]
async fn deleting_a_version_forgets_its_known_license() {
let data_dir = tempfile::tempdir().unwrap();
let settings = Settings {
registry: kellnr_settings::Registry {
data_dir: data_dir.path().to_string_lossy().to_string(),
..kellnr_settings::Registry::default()
},
..Settings::default()
};
let storage = Box::new(FSStorage::new(&settings.crates_path()).unwrap()) as DynStorage;
let crate_storage = Arc::new(KellnrCrateStorage::new(&settings, storage));
let docs_storage = Arc::new(DocsStorage::new(Box::new(
FSStorage::new(&settings.docs_path()).unwrap(),
) as DynStorage));
let mut mock_db = MockDb::new();
mock_db.expect_delete_crate().returning(|_, _| Ok(()));
let state = AppStateData {
db: Arc::new(mock_db),
settings: Arc::new(settings),
crate_storage: crate_storage.clone(),
docs_storage,
..kellnr_appstate::test_state()
};
let name = OriginalName::try_from("licensed").unwrap();
let version = Version::try_from("1.0.0").unwrap();
crate_storage
.put(&name, &version, Arc::from(b"crate".as_slice()))
.await
.unwrap();
let licenses = state.download_filters.registry.licenses();
licenses.insert(
&name.to_normalized(),
&version,
kellnr_filter::KnownLicense::Declared("MIT".to_string()),
);
delete_crate_versions_impl(&state, &name, Some(vec![version.clone()]))
.await
.unwrap();
assert_eq!(None, licenses.get(&name.to_normalized(), &version));
}
#[tokio::test]
async fn settings_no_admin_returns_unauthorized() {
let mut mock_db = MockDb::new();
mock_db.expect_validate_session().returning(|_| {
Ok(kellnr_db::SessionInfo {
name: "admin".to_string(),
is_admin: true,
is_read_only: false,
})
});
let (settings, storage) = test_deps();
let r = app(
mock_db,
KellnrCrateStorage::new(&settings, storage),
settings,
)
.oneshot(Request::get("/settings").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(r.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn settings_returns_from_settings() {
let mut mock_db = MockDb::new();
mock_db.expect_validate_session().returning(|_| {
Ok(kellnr_db::SessionInfo {
name: "admin".to_string(),
is_admin: true,
is_read_only: false,
})
});
let (settings, storage) = test_deps();
let r = app(
mock_db,
KellnrCrateStorage::new(&settings, storage),
settings,
)
.oneshot(
Request::get("/settings")
.header(
header::COOKIE,
encode_cookies([(constants::COOKIE_SESSION_ID, "cookie")]),
)
.header(header::CONTENT_TYPE, "application/json")
.header(header::AUTHORIZATION, "token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let result_status = r.status();
let result_msg = r.into_body().collect().await.unwrap().to_bytes();
let result_response = serde_json::from_slice::<SettingsResponse>(&result_msg).unwrap();
let tmp = kellnr_settings::test_settings();
let psq = Postgresql {
pwd: String::default(),
..tmp.postgresql
};
let setup = Setup {
admin_pwd: REDACTED.to_string(),
..tmp.setup
};
let expected_state = Settings {
postgresql: psq,
setup,
..tmp
};
let result_settings: Settings =
serde_json::from_value(serde_json::Value::Object(result_response.settings)).unwrap();
assert_eq!(result_status, StatusCode::OK);
assert_eq!(result_settings, expected_state);
}
#[tokio::test]
async fn settings_response_sources_reflect_prov_overrides() {
use kellnr_settings::{Config, ConfigSource, SettingsProv};
let mut mock_db = MockDb::new();
mock_db.expect_validate_session().returning(|_| {
Ok(kellnr_db::SessionInfo {
name: "admin".to_string(),
is_admin: true,
is_read_only: false,
})
});
let (settings, storage) = test_deps();
let prov: SettingsProv = Config::new()
.add_toml_str(
"override.toml",
"[registry]\ndata_dir = \"/tmp/from-toml\"\n",
)
.build::<SettingsProv>()
.unwrap();
let app: Router = Router::new()
.route("/settings", get(crate::ui::settings))
.with_state(AppStateData {
db: Arc::new(mock_db),
signing_key: Key::from(TEST_KEY),
settings: Arc::new(settings.clone()),
settings_prov: Arc::new(prov),
crate_storage: Arc::new(KellnrCrateStorage::new(&settings, storage)),
..kellnr_appstate::test_state()
});
let r = app
.oneshot(
Request::get("/settings")
.header(
header::COOKIE,
encode_cookies([(constants::COOKIE_SESSION_ID, "cookie")]),
)
.header(header::CONTENT_TYPE, "application/json")
.header(header::AUTHORIZATION, "token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(r.status(), StatusCode::OK);
let body = r.into_body().collect().await.unwrap().to_bytes();
let response: SettingsResponse = serde_json::from_slice(&body).unwrap();
assert_eq!(
response.sources.get("registry.data_dir"),
Some(&ConfigSource::Toml),
"TOML-set leaf must report ConfigSource::Toml"
);
assert_eq!(
response.sources.get("docs.enabled"),
Some(&ConfigSource::Default),
);
let data_dir_leaf = response
.leaves
.iter()
.find(|l| l.key == "registry.data_dir")
.expect("registry.data_dir leaf must be present");
assert_eq!(
data_dir_leaf.value,
serde_json::json!("/tmp/from-toml"),
"leaf value matches the TOML override"
);
assert_eq!(data_dir_leaf.source, ConfigSource::Toml);
assert_eq!(data_dir_leaf.kind, LeafKind::String);
assert!(!data_dir_leaf.secret);
assert_eq!(
data_dir_leaf.cli_flag.as_deref(),
Some("--registry-data-dir, -d"),
"cli_flag comes from clap reflection, long + short"
);
let docs_enabled = response
.leaves
.iter()
.find(|l| l.key == "docs.enabled")
.expect("docs.enabled leaf must be present");
assert_eq!(docs_enabled.kind, LeafKind::Boolean);
assert_eq!(docs_enabled.source, ConfigSource::Default);
assert_eq!(docs_enabled.default, serde_json::json!(false));
let secret_leaf = response
.leaves
.iter()
.find(|l| l.key == "s3.secret_key")
.expect("s3.secret_key leaf must be present");
assert!(secret_leaf.secret, "s3.secret_key must be flagged secret");
}
#[tokio::test]
async fn settings_response_redacts_secrets() {
use kellnr_settings::{Config, SettingsProv};
const SECRETS: [&str; 3] = ["s3-secret-value", "admin-pwd-value", "admin-token-value"];
let toml = format!(
"[s3]\nsecret_key = \"{}\"\n[setup]\nadmin_pwd = \"{}\"\nadmin_token = \"{}\"\n",
SECRETS[0], SECRETS[1], SECRETS[2]
);
let prov: SettingsProv = Config::new()
.add_toml_str("secrets.toml", &toml)
.build::<SettingsProv>()
.unwrap();
let mut settings = kellnr_settings::test_settings();
settings.s3.secret_key = Some(SECRETS[0].to_string());
settings.setup.admin_pwd = SECRETS[1].to_string();
settings.setup.admin_token = Some(SECRETS[2].to_string());
let mut mock_db = MockDb::new();
mock_db.expect_validate_session().returning(|_| {
Ok(kellnr_db::SessionInfo {
name: "admin".to_string(),
is_admin: true,
is_read_only: false,
})
});
let (_, storage) = test_deps();
let app: Router = Router::new()
.route("/settings", get(crate::ui::settings))
.with_state(AppStateData {
db: Arc::new(mock_db),
signing_key: Key::from(TEST_KEY),
settings: Arc::new(settings.clone()),
settings_prov: Arc::new(prov),
crate_storage: Arc::new(KellnrCrateStorage::new(&settings, storage)),
..kellnr_appstate::test_state()
});
let r = app
.oneshot(
Request::get("/settings")
.header(
header::COOKIE,
encode_cookies([(constants::COOKIE_SESSION_ID, "cookie")]),
)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(r.status(), StatusCode::OK);
let body = r.into_body().collect().await.unwrap().to_bytes();
let raw = String::from_utf8(body.to_vec()).unwrap();
for secret in SECRETS {
assert!(!raw.contains(secret), "response leaks secret {secret}");
}
assert!(!raw.contains("\"admin_pwd\":\"admin\""));
let response: SettingsResponse = serde_json::from_slice(&body).unwrap();
let redacted = serde_json::json!(REDACTED);
assert_eq!(response.settings["s3"]["secret_key"], redacted);
assert_eq!(response.settings["setup"]["admin_pwd"], redacted);
assert_eq!(response.settings["setup"]["admin_token"], redacted);
assert_eq!(response.defaults["setup"]["admin_pwd"], redacted);
assert_eq!(
response.defaults["setup"]["admin_token"],
serde_json::Value::Null
);
for leaf in response.leaves.iter().filter(|leaf| leaf.secret) {
assert_eq!(leaf.kind, LeafKind::String, "{}", leaf.key);
assert!(
leaf.value.is_null() || leaf.value == redacted,
"secret leaf {} is not redacted",
leaf.key
);
}
let unset_secret = response
.leaves
.iter()
.find(|l| l.key == "oauth2.client_secret")
.expect("oauth2.client_secret leaf must be present");
assert!(unset_secret.value.is_null(), "unset secret must be null");
}
#[tokio::test]
async fn docs_enabled_no_auth_returns_ok() {
let mock_db = MockDb::new();
let (settings, storage) = test_deps();
let r = app(
mock_db,
KellnrCrateStorage::new(&settings, storage),
settings,
)
.oneshot(Request::get("/docs_enabled").body(Body::empty()).unwrap())
.await
.unwrap();
assert_eq!(r.status(), StatusCode::OK);
}
#[tokio::test]
async fn docs_enabled_returns_false_by_default() {
let mock_db = MockDb::new();
let (settings, storage) = test_deps();
let r = app(
mock_db,
KellnrCrateStorage::new(&settings, storage),
settings,
)
.oneshot(Request::get("/docs_enabled").body(Body::empty()).unwrap())
.await
.unwrap();
let result_status = r.status();
let result_msg = r.into_body().collect().await.unwrap().to_bytes();
let result = serde_json::from_slice::<DocsEnabledResponse>(&result_msg).unwrap();
assert_eq!(StatusCode::OK, result_status);
assert!(!result.enabled);
}
#[tokio::test]
async fn docs_enabled_returns_true_when_enabled() {
let mock_db = MockDb::new();
let (mut settings, storage) = test_deps();
settings.docs.enabled = true;
let r = app(
mock_db,
KellnrCrateStorage::new(&settings, storage),
settings,
)
.oneshot(Request::get("/docs_enabled").body(Body::empty()).unwrap())
.await
.unwrap();
let result_status = r.status();
let result_msg = r.into_body().collect().await.unwrap().to_bytes();
let result = serde_json::from_slice::<DocsEnabledResponse>(&result_msg).unwrap();
assert_eq!(StatusCode::OK, result_status);
assert!(result.enabled);
}
#[tokio::test]
async fn sponsor_links_enabled_returns_true_by_default() {
let mock_db = MockDb::new();
let (settings, storage) = test_deps();
let r = app(
mock_db,
KellnrCrateStorage::new(&settings, storage),
settings,
)
.oneshot(
Request::get("/sponsor_links_enabled")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let result_status = r.status();
let result_msg = r.into_body().collect().await.unwrap().to_bytes();
let result = serde_json::from_slice::<SponsorLinksEnabledResponse>(&result_msg).unwrap();
assert_eq!(StatusCode::OK, result_status);
assert!(result.enabled);
}
#[tokio::test]
async fn sponsor_links_enabled_returns_false_when_disabled() {
let mock_db = MockDb::new();
let (mut settings, storage) = test_deps();
settings.registry.sponsor_links_enabled = false;
let r = app(
mock_db,
KellnrCrateStorage::new(&settings, storage),
settings,
)
.oneshot(
Request::get("/sponsor_links_enabled")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let result_status = r.status();
let result_msg = r.into_body().collect().await.unwrap().to_bytes();
let result = serde_json::from_slice::<SponsorLinksEnabledResponse>(&result_msg).unwrap();
assert_eq!(StatusCode::OK, result_status);
assert!(!result.enabled);
}
#[tokio::test]
async fn build_rust_doc_crate_not_found() {
let mut mock_db = MockDb::new();
mock_db
.expect_get_crate_id()
.with(eq(NormalizedName::from_unchecked("foobar".to_string())))
.returning(move |_| Ok(None));
mock_db
.expect_validate_session()
.with(eq("cookie"))
.returning(move |_| {
Ok(kellnr_db::SessionInfo {
name: "user".to_string(),
is_admin: true,
is_read_only: false,
})
});
let (mut settings, storage) = test_deps();
settings.docs.enabled = true;
let r = app(
mock_db,
KellnrCrateStorage::new(&settings, storage),
settings,
)
.oneshot(
Request::post("/build?package=foobar&version=1.0.0")
.header(
header::COOKIE,
encode_cookies([(constants::COOKIE_SESSION_ID, "cookie")]),
)
.header(header::CONTENT_TYPE, "application/json")
.header(header::AUTHORIZATION, "token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(r.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn build_rust_doc_crate_not_found_for_non_owner() {
let mut mock_db = MockDb::new();
mock_db
.expect_validate_session()
.with(eq("cookie"))
.returning(move |_| {
Ok(kellnr_db::SessionInfo {
name: "user".to_string(),
is_admin: false,
is_read_only: false,
})
});
mock_db
.expect_is_owner()
.with(
eq(NormalizedName::from_unchecked("foobar".to_string())),
eq("user"),
)
.returning(move |_, _| Ok(false));
let (mut settings, storage) = test_deps();
settings.docs.enabled = true;
let r = app(
mock_db,
KellnrCrateStorage::new(&settings, storage),
settings,
)
.oneshot(
Request::post("/build?package=foobar&version=1.0.0")
.header(
header::COOKIE,
encode_cookies([(constants::COOKIE_SESSION_ID, "cookie")]),
)
.header(header::CONTENT_TYPE, "application/json")
.header(header::AUTHORIZATION, "token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(r.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn build_rust_doc_version_not_found() {
let mut mock_db = MockDb::new();
mock_db
.expect_get_crate_id()
.with(eq(NormalizedName::from_unchecked("foobar".to_string())))
.returning(move |_| Ok(Some(1)));
mock_db
.expect_validate_session()
.with(eq("cookie"))
.returning(move |_| {
Ok(kellnr_db::SessionInfo {
name: "user".to_string(),
is_admin: false,
is_read_only: false,
})
});
mock_db
.expect_is_owner()
.with(
eq(NormalizedName::from_unchecked("foobar".to_string())),
eq("user"),
)
.returning(move |_, _| Ok(true));
mock_db
.expect_get_user()
.with(eq("user"))
.returning(move |_| {
Ok(User {
id: 0,
name: "user".to_string(),
pwd: String::new(),
salt: String::new(),
is_admin: false,
is_read_only: false,
created: String::new(),
})
});
mock_db
.expect_crate_version_exists()
.with(eq(1), eq("1.0.0"))
.returning(move |_, _| Ok(false));
let (mut settings, storage) = test_deps();
settings.docs.enabled = true;
let r = app(
mock_db,
KellnrCrateStorage::new(&settings, storage),
settings,
)
.oneshot(
Request::post("/build?package=foobar&version=1.0.0")
.header(
header::COOKIE,
encode_cookies([(constants::COOKIE_SESSION_ID, "cookie")]),
)
.header(header::CONTENT_TYPE, "application/json")
.header(header::AUTHORIZATION, "token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(r.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn build_rust_doc_not_owner() {
let mut mock_db = MockDb::new();
mock_db
.expect_get_crate_id()
.with(eq(NormalizedName::from_unchecked("foobar".to_string())))
.returning(move |_| Ok(Some(1)));
mock_db
.expect_validate_session()
.with(eq("cookie"))
.returning(move |_| {
Ok(kellnr_db::SessionInfo {
name: "user".to_string(),
is_admin: false,
is_read_only: false,
})
});
mock_db
.expect_crate_version_exists()
.with(eq(1), eq("1.0.0"))
.returning(move |_, _| Ok(true));
mock_db
.expect_is_owner()
.with(
eq(NormalizedName::from_unchecked("foobar".to_string())),
eq("user"),
)
.returning(move |_, _| Ok(false));
mock_db
.expect_get_user()
.with(eq("user"))
.returning(move |_| {
Ok(User {
id: 0,
name: "user".to_string(),
pwd: String::new(),
salt: String::new(),
is_admin: false,
is_read_only: false,
created: String::new(),
})
});
let (mut settings, storage) = test_deps();
settings.docs.enabled = true;
let r = app(
mock_db,
KellnrCrateStorage::new(&settings, storage),
settings,
)
.oneshot(
Request::post("/build?package=foobar&version=1.0.0")
.header(
header::COOKIE,
encode_cookies([(constants::COOKIE_SESSION_ID, "cookie")]),
)
.header(header::CONTENT_TYPE, "application/json")
.header(header::AUTHORIZATION, "token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(r.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn build_rust_doc_is_owner() {
let mut mock_db = MockDb::new();
mock_db
.expect_get_crate_id()
.with(eq(NormalizedName::from_unchecked("foobar".to_string())))
.returning(move |_| Ok(Some(1)));
mock_db
.expect_validate_session()
.with(eq("cookie"))
.returning(move |_| {
Ok(kellnr_db::SessionInfo {
name: "user".to_string(),
is_admin: false,
is_read_only: false,
})
});
mock_db
.expect_crate_version_exists()
.with(eq(1), eq("1.0.0"))
.returning(move |_, _| Ok(true));
mock_db
.expect_is_owner()
.with(
eq(NormalizedName::from_unchecked("foobar".to_string())),
eq("user"),
)
.returning(move |_, _| Ok(true));
mock_db
.expect_get_user()
.with(eq("user"))
.returning(move |_| {
Ok(User {
id: 0,
name: "user".to_string(),
pwd: String::new(),
salt: String::new(),
is_admin: false,
is_read_only: false,
created: String::new(),
})
});
mock_db
.expect_add_doc_queue()
.with(
eq(NormalizedName::from_unchecked("foobar".to_string())),
eq(Version::try_from("1.0.0").unwrap()),
always(),
)
.times(1)
.returning(move |_, _, _| Ok(()));
let (mut settings, storage) = test_deps();
settings.docs.enabled = true;
let r = app(
mock_db,
KellnrCrateStorage::new(&settings, storage),
settings,
)
.oneshot(
Request::post("/build?package=foobar&version=1.0.0")
.header(
header::COOKIE,
encode_cookies([(constants::COOKIE_SESSION_ID, "cookie")]),
)
.header(header::CONTENT_TYPE, "application/json")
.header(header::AUTHORIZATION, "token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(r.status(), StatusCode::OK);
}
#[tokio::test]
async fn build_rust_doc_read_only_owner_is_rejected() {
let mut mock_db = MockDb::new();
mock_db
.expect_get_crate_id()
.with(eq(NormalizedName::from_unchecked("foobar".to_string())))
.returning(move |_| Ok(Some(1)));
mock_db
.expect_validate_session()
.with(eq("cookie"))
.returning(move |_| {
Ok(kellnr_db::SessionInfo {
name: "user".to_string(),
is_admin: false,
is_read_only: false,
})
});
mock_db
.expect_crate_version_exists()
.with(eq(1), eq("1.0.0"))
.returning(move |_, _| Ok(true));
mock_db
.expect_is_owner()
.with(
eq(NormalizedName::from_unchecked("foobar".to_string())),
eq("user"),
)
.returning(move |_, _| Ok(true));
mock_db
.expect_get_user()
.with(eq("user"))
.returning(move |_| {
Ok(User {
id: 0,
name: "user".to_string(),
pwd: String::new(),
salt: String::new(),
is_admin: false,
is_read_only: true,
created: String::new(),
})
});
mock_db
.expect_add_doc_queue()
.with(
eq(NormalizedName::from_unchecked("foobar".to_string())),
eq(Version::try_from("1.0.0").unwrap()),
always(),
)
.times(0)
.returning(move |_, _, _| Ok(()));
let (mut settings, storage) = test_deps();
settings.docs.enabled = true;
let r = app(
mock_db,
KellnrCrateStorage::new(&settings, storage),
settings,
)
.oneshot(
Request::post("/build?package=foobar&version=1.0.0")
.header(
header::COOKIE,
encode_cookies([(constants::COOKIE_SESSION_ID, "cookie")]),
)
.header(header::CONTENT_TYPE, "application/json")
.header(header::AUTHORIZATION, "token")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(r.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn build_rust_doc_not_owner_but_admin() {
let mut mock_db = MockDb::new();
mock_db
.expect_get_crate_id()
.with(eq(NormalizedName::from_unchecked("foobar".to_string())))
.returning(move |_| Ok(Some(1)));
mock_db
.expect_validate_session()
.with(eq("cookie"))
.returning(move |_| {
Ok(kellnr_db::SessionInfo {
name: "user".to_string(),
is_admin: true,
is_read_only: false,
})
});
mock_db
.expect_crate_version_exists()
.with(eq(1), eq("1.0.0"))
.returning(move |_, _| Ok(true));
mock_db
.expect_is_owner()
.with(
eq(NormalizedName::from_unchecked("foobar".to_string())),
eq("user"),
)
.returning(move |_, _| Ok(false));
mock_db
.expect_get_user()
.with(eq("user"))
.returning(move |_| {
Ok(User {
id: 0,
name: "user".to_string(),
pwd: String::new(),
salt: String::new(),
is_admin: true,
is_read_only: false,
created: String::new(),
})
});
mock_db
.expect_add_doc_queue()
.with(
eq(NormalizedName::from_unchecked("foobar".to_string())),
eq(Version::try_from("1.0.0").unwrap()),
always(),
)
.times(1)
.returning(move |_, _, _| Ok(()));
let (mut settings, storage) = test_deps();
settings.docs.enabled = true;
let r = app(
mock_db,
KellnrCrateStorage::new(&settings, storage),
settings,
)
.oneshot(
Request::post("/build?package=foobar&version=1.0.0")
.header(header::CONTENT_TYPE, "application/json")
.header(header::AUTHORIZATION, "token")
.header(
header::COOKIE,
encode_cookies([(constants::COOKIE_SESSION_ID, "cookie")]),
)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(r.status(), StatusCode::OK);
}
fn migrate_storage_app(
mock_db: MockDb,
settings: Settings,
docs_storage: DocsStorage,
) -> Router {
Router::new()
.route("/migrate-storage", post(migrate_docs_storage))
.with_state(AppStateData {
db: Arc::new(mock_db),
signing_key: Key::from(TEST_KEY),
settings: Arc::new(settings),
docs_storage: Arc::new(docs_storage),
..kellnr_appstate::test_state()
})
}
fn fs_docs_storage(dir: &std::path::Path) -> DocsStorage {
DocsStorage::new(Box::new(FSStorage::new(dir.to_str().unwrap()).unwrap()) as DynStorage)
}
fn settings_with_docs_bucket_configured(data_dir: &std::path::Path) -> Settings {
let mut settings = kellnr_settings::test_settings();
settings.registry.data_dir = data_dir.to_str().unwrap().to_string();
settings.s3.enabled = true;
settings.s3.docs_bucket = Some("kellnr-docs".to_string());
settings
}
fn admin_session_mock_db() -> MockDb {
let mut mock_db = MockDb::new();
mock_db.expect_validate_session().returning(|_| {
Ok(kellnr_db::SessionInfo {
name: "admin".to_string(),
is_admin: true,
is_read_only: false,
})
});
mock_db
}
#[tokio::test]
async fn migrate_docs_storage_returns_401_without_session() {
let local = tempfile::tempdir().unwrap();
let target = tempfile::tempdir().unwrap();
let settings = settings_with_docs_bucket_configured(local.path());
let r = migrate_storage_app(MockDb::new(), settings, fs_docs_storage(target.path()))
.oneshot(
Request::post("/migrate-storage")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(r.status(), StatusCode::UNAUTHORIZED);
}
#[tokio::test]
async fn migrate_docs_storage_returns_403_for_non_admin() {
let mut mock_db = MockDb::new();
mock_db.expect_validate_session().returning(|_| {
Ok(kellnr_db::SessionInfo {
name: "user".to_string(),
is_admin: false,
is_read_only: false,
})
});
let local = tempfile::tempdir().unwrap();
let target = tempfile::tempdir().unwrap();
let settings = settings_with_docs_bucket_configured(local.path());
let r = migrate_storage_app(mock_db, settings, fs_docs_storage(target.path()))
.oneshot(
Request::post("/migrate-storage")
.header(
header::COOKIE,
encode_cookies([(constants::COOKIE_SESSION_ID, "cookie")]),
)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(r.status(), StatusCode::FORBIDDEN);
}
#[tokio::test]
async fn migrate_docs_storage_returns_400_when_backend_is_local_disk() {
let local = tempfile::tempdir().unwrap();
let target = tempfile::tempdir().unwrap();
let mut settings = kellnr_settings::test_settings();
settings.registry.data_dir = local.path().to_str().unwrap().to_string();
let r = migrate_storage_app(
admin_session_mock_db(),
settings,
fs_docs_storage(target.path()),
)
.oneshot(
Request::post("/migrate-storage")
.header(
header::COOKIE,
encode_cookies([(constants::COOKIE_SESSION_ID, "cookie")]),
)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(r.status(), StatusCode::BAD_REQUEST);
}
#[tokio::test]
async fn migrate_docs_storage_returns_zero_count_when_local_dir_missing() {
let local = tempfile::tempdir().unwrap();
let target = tempfile::tempdir().unwrap();
let settings = settings_with_docs_bucket_configured(local.path());
let r = migrate_storage_app(
admin_session_mock_db(),
settings,
fs_docs_storage(target.path()),
)
.oneshot(
Request::post("/migrate-storage")
.header(
header::COOKIE,
encode_cookies([(constants::COOKIE_SESSION_ID, "cookie")]),
)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(r.status(), StatusCode::OK);
let body = r.into_body().collect().await.unwrap().to_bytes();
let response: MigrateDocsStorageResponse = serde_json::from_slice(&body).unwrap();
assert!(!response.started);
assert_eq!(response.crate_versions_found, 0);
}
#[tokio::test]
async fn migrate_docs_storage_copies_local_docs_into_configured_backend() {
let local = tempfile::tempdir().unwrap();
let target = tempfile::tempdir().unwrap();
let settings = settings_with_docs_bucket_configured(local.path());
let docs_path_string = settings.docs_path();
let docs_path = std::path::Path::new(&docs_path_string);
tokio::fs::create_dir_all(docs_path.join("crate-a/1.0.0/doc"))
.await
.unwrap();
tokio::fs::write(
docs_path.join("crate-a/1.0.0/doc/index.html"),
b"crate-a docs",
)
.await
.unwrap();
tokio::fs::create_dir_all(docs_path.join("crate-b/2.0.0/doc/nested"))
.await
.unwrap();
tokio::fs::write(
docs_path.join("crate-b/2.0.0/doc/nested/page.html"),
b"crate-b nested docs",
)
.await
.unwrap();
let target_docs_storage = fs_docs_storage(target.path());
let r = migrate_storage_app(
admin_session_mock_db(),
settings,
fs_docs_storage(target.path()),
)
.oneshot(
Request::post("/migrate-storage")
.header(
header::COOKIE,
encode_cookies([(constants::COOKIE_SESSION_ID, "cookie")]),
)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(r.status(), StatusCode::ACCEPTED);
let body = r.into_body().collect().await.unwrap().to_bytes();
let response: MigrateDocsStorageResponse = serde_json::from_slice(&body).unwrap();
assert!(response.started);
assert_eq!(response.crate_versions_found, 2);
let key_a = DocsStorage::file_key("crate-a", "1.0.0", "doc/index.html");
let key_b = DocsStorage::file_key("crate-b", "2.0.0", "doc/nested/page.html");
for _ in 0..50 {
if target_docs_storage.exists(&key_a).await.unwrap()
&& target_docs_storage.exists(&key_b).await.unwrap()
{
break;
}
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert_eq!(
target_docs_storage.get(&key_a).await.unwrap(),
Bytes::from_static(b"crate-a docs")
);
assert_eq!(
target_docs_storage.get(&key_b).await.unwrap(),
Bytes::from_static(b"crate-b nested docs")
);
}
#[tokio::test]
async fn statistic_returns_sparse_statistics() {
let mut mock_db = MockDb::new();
mock_db
.expect_get_total_unique_crates()
.returning(move || Err(DbError::FailedToCountCrates));
mock_db
.expect_get_total_crate_versions()
.returning(move || Err(DbError::FailedToCountCrateVersions));
mock_db
.expect_get_total_downloads()
.returning(move || Err(DbError::FailedToCountTotalDownloads));
mock_db
.expect_get_top_crates_downloads()
.with(eq(3), eq(CrateVisibility::Unrestricted))
.returning(move |_, _| Ok(vec![("top1".to_string(), 1000)]));
mock_db
.expect_get_last_updated_crate()
.returning(move |_| Ok(None));
mock_db
.expect_get_total_unique_cached_crates()
.returning(move || Err(DbError::FailedToCountCrates));
mock_db
.expect_get_total_cached_crate_versions()
.returning(move || Err(DbError::FailedToCountCrateVersions));
mock_db
.expect_get_total_cached_downloads()
.returning(move || Err(DbError::FailedToCountTotalDownloads));
let (settings, storage) = test_deps();
let r = app(
mock_db,
KellnrCrateStorage::new(&settings, storage),
settings,
)
.oneshot(Request::get("/statistic").body(Body::empty()).unwrap())
.await
.unwrap();
let result_msg = r.into_body().collect().await.unwrap().to_bytes();
let result_stat = serde_json::from_slice::<Statistic>(&result_msg).unwrap();
let expect = Statistic {
num_crates: 0,
num_crate_versions: 0,
num_crate_downloads: 0,
num_proxy_crates: 0,
num_proxy_crate_versions: 0,
num_proxy_crate_downloads: 0,
top_crates: TopCrates {
first: ("top1".to_string(), 1000),
second: (String::new(), 0),
third: (String::new(), 0),
},
last_updated_crate: None,
proxy_enabled: false,
};
assert_eq!(expect, result_stat);
}
#[tokio::test]
async fn statistic_returns_empty_statistics() {
let mut mock_db = MockDb::new();
mock_db
.expect_get_total_unique_crates()
.returning(move || Err(DbError::FailedToCountCrates));
mock_db
.expect_get_total_crate_versions()
.returning(move || Err(DbError::FailedToCountCrateVersions));
mock_db
.expect_get_total_downloads()
.returning(move || Err(DbError::FailedToCountTotalDownloads));
mock_db
.expect_get_top_crates_downloads()
.with(eq(3), eq(CrateVisibility::Unrestricted))
.returning(move |_, _| Err(DbError::FailedToCountTotalDownloads));
mock_db
.expect_get_last_updated_crate()
.returning(move |_| Ok(None));
mock_db
.expect_get_total_unique_cached_crates()
.returning(move || Err(DbError::FailedToCountCrates));
mock_db
.expect_get_total_cached_crate_versions()
.returning(move || Err(DbError::FailedToCountCrateVersions));
mock_db
.expect_get_total_cached_downloads()
.returning(move || Err(DbError::FailedToCountTotalDownloads));
let (settings, storage) = test_deps();
let r = app(
mock_db,
KellnrCrateStorage::new(&settings, storage),
settings,
)
.oneshot(Request::get("/statistic").body(Body::empty()).unwrap())
.await
.unwrap();
let result_msg = r.into_body().collect().await.unwrap().to_bytes();
let result_stat = serde_json::from_slice::<Statistic>(&result_msg).unwrap();
let expect = Statistic {
num_crates: 0,
num_crate_versions: 0,
num_crate_downloads: 0,
num_proxy_crates: 0,
num_proxy_crate_versions: 0,
num_proxy_crate_downloads: 0,
top_crates: TopCrates {
first: (String::new(), 0),
second: (String::new(), 0),
third: (String::new(), 0),
},
last_updated_crate: None,
proxy_enabled: false,
};
assert_eq!(expect, result_stat);
}
#[tokio::test]
async fn statistic_returns_crate_statistics() {
let mut mock_db = MockDb::new();
mock_db
.expect_get_total_unique_crates()
.returning(move || Ok(1000));
mock_db
.expect_get_total_crate_versions()
.returning(move || Ok(10000));
mock_db
.expect_get_total_downloads()
.returning(move || Ok(100_000));
mock_db
.expect_get_top_crates_downloads()
.with(eq(3), eq(CrateVisibility::Unrestricted))
.returning(move |_, _| {
Ok(vec![
("top1".to_string(), 1000),
("top2".to_string(), 500),
("top3".to_string(), 100),
])
});
mock_db
.expect_get_total_unique_cached_crates()
.returning(move || Ok(9999));
mock_db
.expect_get_total_cached_crate_versions()
.returning(move || Ok(99999));
mock_db
.expect_get_total_cached_downloads()
.returning(move || Ok(999_999));
mock_db.expect_get_last_updated_crate().returning(move |_| {
Ok(Some((
OriginalName::from_unchecked("foobar".to_string()),
Version::try_from("1.0.0").unwrap(),
)))
});
let (settings, storage) = test_deps();
let r = app(
mock_db,
KellnrCrateStorage::new(&settings, storage),
settings,
)
.oneshot(Request::get("/statistic").body(Body::empty()).unwrap())
.await
.unwrap();
let result_msg = r.into_body().collect().await.unwrap().to_bytes();
let result_stat = serde_json::from_slice::<Statistic>(&result_msg).unwrap();
let expect = Statistic {
num_crates: 1000,
num_crate_versions: 10000,
num_crate_downloads: 100_000,
num_proxy_crates: 9999,
num_proxy_crate_versions: 99999,
num_proxy_crate_downloads: 999_999,
top_crates: TopCrates {
first: ("top1".to_string(), 1000),
second: ("top2".to_string(), 500),
third: ("top3".to_string(), 100),
},
last_updated_crate: Some((
OriginalName::from_unchecked("foobar".to_string()),
Version::try_from("1.0.0").unwrap(),
)),
proxy_enabled: false,
};
assert_eq!(expect, result_stat);
}
#[tokio::test]
async fn kellnr_version_returns_version() {
let (settings, storage) = test_deps();
let mock_db = MockDb::new();
let r = app(
mock_db,
KellnrCrateStorage::new(&settings, storage),
settings,
)
.oneshot(Request::get("/version").body(Body::empty()).unwrap())
.await
.unwrap();
let result_msg = r.into_body().collect().await.unwrap().to_bytes();
let result_version = serde_json::from_slice::<KellnrVersion>(&result_msg).unwrap();
assert_eq!("0.0.0-unknown", result_version.version);
}
#[tokio::test]
async fn search_not_hits_returns_nothing() {
let mut mock_db = MockDb::new();
let (settings, storage) = test_deps();
mock_db
.expect_search_in_crate_name_and_description()
.with(
eq("doesnotexist"),
eq(10),
eq(0),
eq(false),
eq(CrateVisibility::Unrestricted),
)
.returning(move |_name, _, _, _, _| Ok(vec![]));
mock_db
.expect_count_by_crate_name_and_description()
.returning(|_, _, _| Ok(0));
let r = app(
mock_db,
KellnrCrateStorage::new(&settings, storage),
settings,
)
.oneshot(
Request::get("/search?name=doesnotexist")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let result_status = r.status();
let result_msg = r.into_body().collect().await.unwrap().to_bytes();
let result_crates = serde_json::from_slice::<Pagination>(&result_msg).unwrap();
assert_eq!(StatusCode::OK, result_status);
assert_eq!(0, result_crates.crates.len());
assert_eq!(0, result_crates.page);
assert_eq!(10, result_crates.page_size);
}
#[tokio::test]
async fn search_rejects_overlong_query() {
let mock_db = MockDb::new();
let (settings, storage) = test_deps();
let r = app(
mock_db,
KellnrCrateStorage::new(&settings, storage),
settings,
)
.oneshot(
Request::get(format!("/search?name={}", "a".repeat(65)))
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(StatusCode::BAD_REQUEST, r.status());
}
#[tokio::test]
async fn search_accepts_multi_word_query() {
let mut mock_db = MockDb::new();
let (settings, storage) = test_deps();
mock_db
.expect_search_in_crate_name_and_description()
.with(
eq("vcard parser"),
eq(10),
eq(0),
eq(false),
eq(CrateVisibility::Unrestricted),
)
.returning(|_, _, _, _, _| Ok(vec![]));
mock_db
.expect_count_by_crate_name_and_description()
.returning(|_, _, _| Ok(0));
let r = app(
mock_db,
KellnrCrateStorage::new(&settings, storage),
settings,
)
.oneshot(
Request::get("/search?name=vcard%20parser")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(StatusCode::OK, r.status());
}
#[tokio::test]
async fn search_returns_only_searched_results() {
let mut mock_db = MockDb::new();
let (settings, storage) = test_deps();
let test_crate_summary = CrateOverview {
name: "hello".to_string(),
version: "1.0.0".to_string(),
date: "12-10-2021 05:41:00".to_string(),
total_downloads: 2,
..Default::default()
};
let tc = test_crate_summary.clone();
mock_db
.expect_search_in_crate_name_and_description()
.with(
eq("hello"),
eq(10),
eq(0),
eq(false),
eq(CrateVisibility::Unrestricted),
)
.returning(move |_, _, _, _, _| Ok(vec![tc.clone()]));
mock_db
.expect_count_by_crate_name_and_description()
.returning(|_, _, _| Ok(0));
let r = app(
mock_db,
KellnrCrateStorage::new(&settings, storage),
settings,
)
.oneshot(
Request::get("/search?name=hello")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let result_status = r.status();
let result_msg = r.into_body().collect().await.unwrap().to_bytes();
let result_crates = serde_json::from_slice::<Pagination>(&result_msg).unwrap();
assert_eq!(StatusCode::OK, result_status);
assert_eq!(1, result_crates.crates.len());
assert_eq!(0, result_crates.page);
assert_eq!(10, result_crates.page_size);
assert_eq!(test_crate_summary, result_crates.crates[0]);
}
#[tokio::test]
async fn search_paginates_like_crate_overview() {
let mut mock_db = MockDb::new();
let (settings, storage) = test_deps();
mock_db
.expect_search_in_crate_name_and_description()
.with(
eq("hello"),
eq(20),
eq(60),
eq(false),
eq(CrateVisibility::Unrestricted),
)
.returning(|_, _, _, _, _| Ok(vec![]));
mock_db
.expect_count_by_crate_name_and_description()
.returning(|_, _, _| Ok(0));
let r = app(
mock_db,
KellnrCrateStorage::new(&settings, storage),
settings,
)
.oneshot(
Request::get("/search?name=hello&page=3&page_size=20")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let result_status = r.status();
let result_msg = r.into_body().collect().await.unwrap().to_bytes();
let result_crates = serde_json::from_slice::<Pagination>(&result_msg).unwrap();
assert_eq!(StatusCode::OK, result_status);
assert_eq!(3, result_crates.page);
assert_eq!(20, result_crates.page_size);
}
#[tokio::test]
async fn search_reports_requested_page_size_on_a_full_page() {
let mut mock_db = MockDb::new();
let (settings, storage) = test_deps();
let hits = (0..2)
.map(|i| CrateOverview {
name: format!("hello{i}"),
version: "1.0.0".to_string(),
..Default::default()
})
.collect::<Vec<_>>();
mock_db
.expect_search_in_crate_name_and_description()
.with(
eq("hello"),
eq(2),
eq(0),
eq(false),
eq(CrateVisibility::Unrestricted),
)
.returning(move |_, _, _, _, _| Ok(hits.clone()));
mock_db
.expect_count_by_crate_name_and_description()
.returning(|_, _, _| Ok(0));
let r = app(
mock_db,
KellnrCrateStorage::new(&settings, storage),
settings,
)
.oneshot(
Request::get("/search?name=hello&page_size=2")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let result_msg = r.into_body().collect().await.unwrap().to_bytes();
let result_crates = serde_json::from_slice::<Pagination>(&result_msg).unwrap();
assert_eq!(2, result_crates.crates.len());
assert_eq!(2, result_crates.page_size);
}
#[tokio::test]
async fn crate_get_crate_information() {
let mut mock_db = MockDb::new();
let (settings, storage) = test_deps();
let expected_crate_data = CrateData {
name: "crate1".to_string(),
owners: vec!["owner1".to_string(), "owner2".to_string()],
max_version: "1.0.0".to_string(),
total_downloads: 5,
last_updated: "12-10-2021 05:41:00".to_string(),
homepage: Some("homepage".to_string()),
description: Some("description".to_string()),
categories: vec!["cat1".to_string(), "cat2".to_string()],
keywords: vec!["key1".to_string(), "key2".to_string()],
authors: vec!["author1".to_string(), "author2".to_string()],
repository: Some("repository".to_string()),
versions: vec![CrateVersionData {
version: "1.0.0".to_string(),
created: "12-10-2021 05:41:00".to_string(),
downloads: 5,
readme: Some("readme".to_string()),
license: Some("MIT".to_string()),
license_file: Some("license".to_string()),
documentation: Some("documentation".to_string()),
dependencies: vec![CrateRegistryDep {
name: "dep1".to_string(),
description: Some("description".to_string()),
version_req: "1.0.0".to_string(),
target: Some("target".to_string()),
kind: Some("dev".to_string()),
registry: Some("registry".to_string()),
..Default::default()
}],
checksum: "checksum".to_string(),
features: BTreeMap::default(),
yanked: false,
links: Some("links".to_string()),
v: 1,
size: 1024,
rust_version: Some("1.85".to_string()),
publisher: Some("admin".to_string()),
publisher_token: Some("ci".to_string()),
}],
};
let ecd = expected_crate_data.clone();
mock_db
.expect_get_crate_data()
.returning(move |_| Ok(ecd.clone()));
mock_db
.expect_is_download_restricted()
.returning(|_| Ok(false));
let r = app(
mock_db,
KellnrCrateStorage::new(&settings, storage),
settings,
)
.oneshot(
Request::get("/crate_data?name=crate1&version=1.0.0")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let result_status = r.status();
let result_msg = r.into_body().collect().await.unwrap().to_bytes();
let result_crate_data = serde_json::from_slice::<CrateData>(&result_msg).unwrap();
assert_eq!(StatusCode::OK, result_status);
assert_eq!(expected_crate_data, result_crate_data);
}
#[tokio::test]
async fn crate_data_hides_dependency_info_of_restricted_crates() {
let mut mock_db = MockDb::new();
mock_db
.expect_is_download_restricted()
.returning(|name| Ok(name.as_str() == "secret"));
let db: Arc<dyn DbProvider> = Arc::new(mock_db);
let dep = |name: &str, registry: Option<&str>| CrateRegistryDep {
name: name.to_string(),
description: Some("description".to_string()),
version_req: "^1".to_string(),
registry: registry.map(str::to_string),
latest_version: Some("2.0.0".to_string()),
outdated: true,
..CrateRegistryDep::default()
};
let mut cd = CrateData {
versions: vec![CrateVersionData {
dependencies: vec![
dep("secret", None),
dep("public", None),
dep("serde", Some(CRATESIO_INDEX)),
],
..CrateVersionData::default()
}],
..CrateData::default()
};
hide_restricted_dep_info(&mut cd, None, &db).await.unwrap();
let deps = &cd.versions[0].dependencies;
assert_eq!(None, deps[0].description);
assert_eq!(None, deps[0].latest_version);
assert!(!deps[0].outdated);
assert_eq!(dep("public", None), deps[1]);
assert_eq!(dep("serde", Some(CRATESIO_INDEX)), deps[2]);
}
fn dependent(name: &str) -> CrateDependent {
CrateDependent {
name: name.to_string(),
version: "1.0.0".to_string(),
outdated: true,
..CrateDependent::default()
}
}
#[tokio::test]
async fn crate_dependents_counts_restricted_dependents_only() {
let mut mock_db = MockDb::new();
mock_db
.expect_is_download_restricted()
.returning(|name| Ok(name.as_str() == "secret"));
let db: Arc<dyn DbProvider> = Arc::new(mock_db);
let mut cd = CrateDependents {
latest_version: Some("2.0.0".to_string()),
dependents: vec![dependent("Secret"), dependent("public")],
restricted: 0,
};
hide_restricted_dependents(&mut cd, None, &db)
.await
.unwrap();
assert_eq!(vec![dependent("public")], cd.dependents);
assert_eq!(1, cd.restricted);
}
#[tokio::test]
async fn crate_dependents_shows_restricted_dependents_with_access() {
let mut mock_db = MockDb::new();
mock_db
.expect_is_download_restricted()
.returning(|name| Ok(name.as_str() == "secret"));
mock_db
.expect_is_crate_user()
.with(
eq(NormalizedName::from_unchecked("secret".to_string())),
eq("user"),
)
.returning(|_, _| Ok(true));
let db: Arc<dyn DbProvider> = Arc::new(mock_db);
let user = MaybeUserOrToken {
name: "user".to_string(),
is_admin: false,
is_read_only: false,
};
let mut cd = CrateDependents {
dependents: vec![dependent("secret")],
..CrateDependents::default()
};
hide_restricted_dependents(&mut cd, Some(&user), &db)
.await
.unwrap();
assert_eq!(vec![dependent("secret")], cd.dependents);
assert_eq!(0, cd.restricted);
}
#[tokio::test]
async fn crate_dependents_returns_dependents() {
let mut mock_db = MockDb::new();
mock_db
.expect_is_download_restricted()
.returning(|name| Ok(name.as_str() == "secret"));
mock_db
.expect_get_crate_dependents()
.with(eq(NormalizedName::from_unchecked("foo".to_string())))
.returning(|_| {
Ok(CrateDependents {
latest_version: Some("2.0.0".to_string()),
dependents: vec![dependent("public"), dependent("secret")],
restricted: 0,
})
});
let (settings, storage) = test_deps();
let response = app(
mock_db,
KellnrCrateStorage::new(&settings, storage),
settings,
)
.oneshot(
Request::get("/crate_dependents?name=Foo")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(StatusCode::OK, response.status());
let body = response.into_body().collect().await.unwrap().to_bytes();
let cd: CrateDependents = serde_json::from_slice(&body).unwrap();
assert_eq!(
CrateDependents {
latest_version: Some("2.0.0".to_string()),
dependents: vec![dependent("public")],
restricted: 1,
},
cd
);
}
#[tokio::test]
async fn crate_dependents_of_restricted_crate_anonymous_returns_not_found() {
let mock_db = restricted_crate_mock(None);
let (settings, storage) = test_deps();
let response = app(
mock_db,
KellnrCrateStorage::new(&settings, storage),
settings,
)
.oneshot(
Request::get("/crate_dependents?name=secret")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(StatusCode::NOT_FOUND, response.status());
}
#[tokio::test]
async fn crate_dependents_of_unknown_crate_returns_not_found() {
let mut mock_db = MockDb::new();
mock_db
.expect_is_download_restricted()
.returning(|_| Ok(false));
mock_db
.expect_get_crate_dependents()
.returning(|name| Err(DbError::CrateNotFound(name.to_string())));
let (settings, storage) = test_deps();
let response = app(
mock_db,
KellnrCrateStorage::new(&settings, storage),
settings,
)
.oneshot(
Request::get("/crate_dependents?name=missing")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(StatusCode::NOT_FOUND, response.status());
}
fn restricted_crate_mock(session_user: Option<(&'static str, bool)>) -> MockDb {
let mut mock_db = MockDb::new();
mock_db
.expect_is_download_restricted()
.with(eq(NormalizedName::from_unchecked("secret".to_string())))
.returning(|_| Ok(true));
mock_db.expect_get_crate_data().returning(|_| {
Ok(CrateData {
name: "secret".to_string(),
..Default::default()
})
});
if let Some((name, is_admin)) = session_user {
mock_db.expect_validate_session().returning(move |_| {
Ok(kellnr_db::SessionInfo {
name: name.to_string(),
is_admin,
is_read_only: false,
})
});
}
mock_db
}
async fn get_secret_crate_data(mock_db: MockDb, with_cookie: bool) -> StatusCode {
let (settings, storage) = test_deps();
let mut request = Request::get("/crate_data?name=secret");
if with_cookie {
request = request.header(
header::COOKIE,
encode_cookies([(constants::COOKIE_SESSION_ID, "cookie")]),
);
}
app(
mock_db,
KellnrCrateStorage::new(&settings, storage),
settings,
)
.oneshot(request.body(Body::empty()).unwrap())
.await
.unwrap()
.status()
}
#[tokio::test]
async fn crate_data_restricted_crate_anonymous_returns_not_found() {
let mock_db = restricted_crate_mock(None);
assert_eq!(
StatusCode::NOT_FOUND,
get_secret_crate_data(mock_db, false).await
);
}
#[tokio::test]
async fn crate_data_restricted_crate_without_access_returns_not_found() {
let mut mock_db = restricted_crate_mock(Some(("user", false)));
mock_db.expect_is_crate_user().returning(|_, _| Ok(false));
mock_db
.expect_is_crate_group_user()
.returning(|_, _| Ok(false));
mock_db.expect_is_owner().returning(|_, _| Ok(false));
assert_eq!(
StatusCode::NOT_FOUND,
get_secret_crate_data(mock_db, true).await
);
}
#[tokio::test]
async fn crate_data_restricted_crate_crate_user_returns_ok() {
let mut mock_db = restricted_crate_mock(Some(("user", false)));
mock_db
.expect_is_crate_user()
.with(
eq(NormalizedName::from_unchecked("secret".to_string())),
eq("user"),
)
.returning(|_, _| Ok(true));
assert_eq!(StatusCode::OK, get_secret_crate_data(mock_db, true).await);
}
#[tokio::test]
async fn crate_data_restricted_crate_admin_returns_ok() {
let mock_db = restricted_crate_mock(Some(("admin", true)));
assert_eq!(StatusCode::OK, get_secret_crate_data(mock_db, true).await);
}
async fn get_with_session(
uri: &str,
mut mock_db: MockDb,
name: &'static str,
is_admin: bool,
) -> StatusCode {
mock_db.expect_validate_session().returning(move |_| {
Ok(kellnr_db::SessionInfo {
name: name.to_string(),
is_admin,
is_read_only: false,
})
});
let (settings, storage) = test_deps();
app(
mock_db,
KellnrCrateStorage::new(&settings, storage),
settings,
)
.oneshot(
Request::get(uri)
.header(
header::COOKIE,
encode_cookies([(constants::COOKIE_SESSION_ID, "cookie")]),
)
.body(Body::empty())
.unwrap(),
)
.await
.unwrap()
.status()
}
#[tokio::test]
async fn crates_user_session_lists_accessible_restricted_crates() {
let mut mock_db = MockDb::new();
mock_db
.expect_get_crate_overview_list()
.with(
eq(10),
eq(0),
eq(false),
eq(CrateVisibility::User("user".to_string())),
eq(CrateSort::Name),
eq(CrateTagFilter::default()),
)
.returning(|_, _, _, _, _, _| Ok(vec![]));
mock_db
.expect_count_crate_overview_list()
.returning(|_, _, _| Ok(42));
let status = get_with_session("/crates", mock_db, "user", false).await;
assert_eq!(StatusCode::OK, status);
}
#[tokio::test]
async fn search_admin_session_lists_all_crates() {
let mut mock_db = MockDb::new();
mock_db
.expect_search_in_crate_name_and_description()
.with(
eq("foo"),
eq(10),
eq(0),
eq(false),
eq(CrateVisibility::All),
)
.returning(|_, _, _, _, _| Ok(vec![]));
mock_db
.expect_count_by_crate_name_and_description()
.returning(|_, _, _| Ok(0));
let status = get_with_session("/search?name=foo", mock_db, "admin", true).await;
assert_eq!(StatusCode::OK, status);
}
#[tokio::test]
async fn statistic_user_session_hides_inaccessible_crate_names() {
let visibility = CrateVisibility::User("user".to_string());
let mut mock_db = MockDb::new();
mock_db.expect_get_total_unique_crates().returning(|| Ok(0));
mock_db
.expect_get_total_crate_versions()
.returning(|| Ok(0));
mock_db.expect_get_total_downloads().returning(|| Ok(0));
mock_db
.expect_get_top_crates_downloads()
.with(eq(3), eq(visibility.clone()))
.returning(|_, _| Ok(vec![]));
mock_db
.expect_get_last_updated_crate()
.with(eq(visibility))
.returning(|_| Ok(None));
mock_db
.expect_get_total_unique_cached_crates()
.returning(|| Ok(0));
mock_db
.expect_get_total_cached_crate_versions()
.returning(|| Ok(0));
mock_db
.expect_get_total_cached_downloads()
.returning(|| Ok(0));
let status = get_with_session("/statistic", mock_db, "user", false).await;
assert_eq!(StatusCode::OK, status);
}
#[tokio::test]
async fn crates_get_page() {
let mut mock_db = MockDb::new();
let (settings, storage) = test_deps();
let test_crate_overview = CrateOverview {
name: "c1".to_string(),
version: "1.0.0".to_string(),
description: None,
total_downloads: 2,
date: "12-10-2021 05:41:00".to_string(),
documentation: None,
repository: None,
is_cache: false,
};
let test_crates = std::iter::repeat_with(|| test_crate_overview.clone())
.take(10)
.collect::<Vec<_>>();
let tc = test_crates.clone();
mock_db
.expect_get_crate_overview_list()
.with(
eq(10),
eq(0),
eq(false),
eq(CrateVisibility::Unrestricted),
eq(CrateSort::Name),
eq(CrateTagFilter::default()),
)
.returning(move |_, _, _, _, _, _| Ok(tc.clone()));
mock_db
.expect_count_crate_overview_list()
.returning(|_, _, _| Ok(42));
let r = app(
mock_db,
KellnrCrateStorage::new(&settings, storage),
settings,
)
.oneshot(Request::get("/crates?page=0").body(Body::empty()).unwrap())
.await
.unwrap();
let result_status = r.status();
let result_msg = r.into_body().collect().await.unwrap().to_bytes();
let result_pagination = serde_json::from_slice::<Pagination>(&result_msg).unwrap();
let expected = test_crates[0..10].to_vec();
assert_eq!(StatusCode::OK, result_status);
assert_eq!(0, result_pagination.page);
assert_eq!(10, result_pagination.page_size);
assert_eq!(10, result_pagination.crates.len());
assert_eq!(expected, result_pagination.crates);
}
#[tokio::test]
async fn crates_get_all_crates() {
let mut mock_db = MockDb::new();
let (settings, storage) = test_deps();
let expected_crate_overview = vec![
CrateOverview {
name: "c1".to_string(),
version: "1.0.0".to_string(),
date: "12-11-2021 05:41:00".to_string(),
total_downloads: 1,
description: Some("Desc".to_string()),
documentation: Some("Docs".to_string()),
repository: None,
is_cache: true,
},
CrateOverview {
name: "c2".to_string(),
version: "2.0.0".to_string(),
date: "12-12-2021 05:41:00".to_string(),
total_downloads: 2,
description: Some("Desc".to_string()),
documentation: Some("Docs".to_string()),
repository: None,
is_cache: true,
},
CrateOverview {
name: "c3".to_string(),
version: "3.0.0".to_string(),
date: "12-09-2021 05:41:00".to_string(),
total_downloads: 3,
description: None,
documentation: None,
repository: None,
is_cache: true,
},
];
let crate_overview = expected_crate_overview.clone();
mock_db
.expect_get_crate_overview_list()
.with(
eq(10),
eq(0),
eq(false),
eq(CrateVisibility::Unrestricted),
eq(CrateSort::Name),
eq(CrateTagFilter::default()),
)
.returning(move |_, _, _, _, _, _| Ok(crate_overview.clone()));
mock_db
.expect_count_crate_overview_list()
.returning(|_, _, _| Ok(42));
let r = app(
mock_db,
KellnrCrateStorage::new(&settings, storage),
settings,
)
.oneshot(Request::get("/crates").body(Body::empty()).unwrap())
.await
.unwrap();
let result_status = r.status();
let result_msg = r.into_body().collect().await.unwrap().to_bytes();
let result_pagination = serde_json::from_slice::<Pagination>(&result_msg).unwrap();
assert_eq!(StatusCode::OK, result_status);
assert_eq!(3, result_pagination.crates.len());
assert_eq!(0, result_pagination.page);
assert_eq!(10, result_pagination.page_size);
assert_eq!(42, result_pagination.total);
assert_eq!(expected_crate_overview, result_pagination.crates);
}
#[tokio::test]
async fn crates_passes_sort_order_to_db() {
let mut mock_db = MockDb::new();
let (settings, storage) = test_deps();
mock_db
.expect_get_crate_overview_list()
.with(
eq(5),
eq(0),
eq(false),
eq(CrateVisibility::Unrestricted),
eq(CrateSort::Downloads),
eq(CrateTagFilter::default()),
)
.returning(|_, _, _, _, _, _| Ok(vec![]));
mock_db
.expect_count_crate_overview_list()
.returning(|_, _, _| Ok(0));
let r = app(
mock_db,
KellnrCrateStorage::new(&settings, storage),
settings,
)
.oneshot(
Request::get("/crates?page_size=5&sort=downloads")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(StatusCode::OK, r.status());
}
#[tokio::test]
async fn crates_passes_keyword_and_category_to_db() {
let mut mock_db = MockDb::new();
let (settings, storage) = test_deps();
let tags = CrateTagFilter {
keyword: Some("cli".to_string()),
category: Some("development-tools".to_string()),
};
mock_db
.expect_get_crate_overview_list()
.with(
eq(10),
eq(0),
eq(false),
eq(CrateVisibility::Unrestricted),
eq(CrateSort::Name),
eq(tags.clone()),
)
.returning(|_, _, _, _, _, _| Ok(vec![]));
mock_db
.expect_count_crate_overview_list()
.with(eq(false), eq(CrateVisibility::Unrestricted), eq(tags))
.returning(|_, _, _| Ok(0));
let r = app(
mock_db,
KellnrCrateStorage::new(&settings, storage),
settings,
)
.oneshot(
Request::get("/crates?keyword=cli&category=development-tools")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(StatusCode::OK, r.status());
}
#[tokio::test]
async fn crates_ignores_empty_keyword_and_category() {
let mut mock_db = MockDb::new();
let (settings, storage) = test_deps();
mock_db
.expect_get_crate_overview_list()
.with(
eq(10),
eq(0),
eq(false),
eq(CrateVisibility::Unrestricted),
eq(CrateSort::Name),
eq(CrateTagFilter::default()),
)
.returning(|_, _, _, _, _, _| Ok(vec![]));
mock_db
.expect_count_crate_overview_list()
.returning(|_, _, _| Ok(0));
let r = app(
mock_db,
KellnrCrateStorage::new(&settings, storage),
settings,
)
.oneshot(
Request::get("/crates?keyword=&category=")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(StatusCode::OK, r.status());
}
#[tokio::test]
async fn crates_rejects_unknown_sort_order() {
let (settings, storage) = test_deps();
let r = app(
MockDb::new(),
KellnrCrateStorage::new(&settings, storage),
settings,
)
.oneshot(
Request::get("/crates?sort=stars")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(StatusCode::BAD_REQUEST, r.status());
}
#[tokio::test]
async fn cratesio_data_returns_data() {
let mock_db = MockDb::new();
let (settings, storage) = test_deps();
let r = app(
mock_db,
KellnrCrateStorage::new(&settings, storage),
settings,
)
.oneshot(
Request::get("/cratesio_data?name=quote")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
let result_status = r.status();
let body =
String::from_utf8(r.into_body().collect().await.unwrap().to_bytes().to_vec()).unwrap();
assert!(body.contains("quote"));
assert_eq!(StatusCode::OK, result_status);
}
#[tokio::test]
async fn cratesio_data_not_found() {
let mock_db = MockDb::new();
let (settings, storage) = test_deps();
let r = app(
mock_db,
KellnrCrateStorage::new(&settings, storage),
settings,
)
.oneshot(
Request::get("/cratesio_data?name=thisdoesnotevenexist")
.body(Body::empty())
.unwrap(),
)
.await
.unwrap();
assert_eq!(r.status(), StatusCode::NOT_FOUND);
}
fn test_deps() -> (Settings, DynStorage) {
let settings = kellnr_settings::test_settings();
let storage = FSStorage::new(&settings.crates_path()).unwrap();
let storage = Box::new(storage) as DynStorage;
(settings, storage)
}
const TEST_KEY: &[u8] = &[1; 64];
fn app(mock_db: MockDb, crate_storage: KellnrCrateStorage, settings: Settings) -> Router {
Router::new()
.route("/search", get(search))
.route("/crates", get(crates))
.route("/crate_data", get(crate_data))
.route("/crate_dependents", get(crate_dependents))
.route("/version", get(kellnr_version))
.route("/statistic", get(statistic))
.route("/build", post(build_rustdoc))
.route("/cratesio_data", get(cratesio_data))
.route("/settings", get(crate::ui::settings))
.route("/docs_enabled", get(docs_enabled))
.route("/sponsor_links_enabled", get(sponsor_links_enabled))
.with_state(AppStateData {
db: Arc::new(mock_db),
signing_key: Key::from(TEST_KEY),
settings: Arc::new(settings),
crate_storage: Arc::new(crate_storage),
..kellnr_appstate::test_state()
})
}
}