Compare commits

..

4 commits

Author SHA1 Message Date
ae59478dd6 tests: add API tests
All checks were successful
ci/woodpecker/push/woodpecker Pipeline was successful
2023-03-07 02:02:23 +01:00
956523d515 feat: add file api endpoint 2023-03-06 22:31:03 +01:00
165f456a42 feat: add get_file endpoint 2023-03-04 21:22:29 +01:00
86631422ec feat: add website version parameter 2023-03-04 20:47:50 +01:00
16 changed files with 954 additions and 118 deletions

52
Cargo.lock generated
View file

@ -936,6 +936,8 @@ dependencies = [
"console", "console",
"lazy_static", "lazy_static",
"linked-hash-map", "linked-hash-map",
"pest",
"pest_derive",
"ron", "ron",
"serde", "serde",
"similar", "similar",
@ -1240,6 +1242,50 @@ version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "478c572c3d73181ff3c2539045f6eb99e5491218eae919370993b890cdbdd98e" checksum = "478c572c3d73181ff3c2539045f6eb99e5491218eae919370993b890cdbdd98e"
[[package]]
name = "pest"
version = "2.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8cbd939b234e95d72bc393d51788aec68aeeb5d51e748ca08ff3aad58cb722f7"
dependencies = [
"thiserror",
"ucd-trie",
]
[[package]]
name = "pest_derive"
version = "2.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a81186863f3d0a27340815be8f2078dd8050b14cd71913db9fbda795e5f707d7"
dependencies = [
"pest",
"pest_generator",
]
[[package]]
name = "pest_generator"
version = "2.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "75a1ef20bf3193c15ac345acb32e26b3dc3223aff4d77ae4fc5359567683796b"
dependencies = [
"pest",
"pest_meta",
"proc-macro2",
"quote",
"syn",
]
[[package]]
name = "pest_meta"
version = "2.5.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5e3b284b1f13a20dc5ebc90aff59a51b8d7137c221131b52a7260c08cbc1cc80"
dependencies = [
"once_cell",
"pest",
"sha2",
]
[[package]] [[package]]
name = "pin-project" name = "pin-project"
version = "0.4.30" version = "0.4.30"
@ -2114,6 +2160,12 @@ version = "1.16.0"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba" checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba"
[[package]]
name = "ucd-trie"
version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9e79c4d996edb816c91e4308506774452e55e95c3c9de07b6729e17e15a5ef81"
[[package]] [[package]]
name = "uncased" name = "uncased"
version = "0.9.7" version = "0.9.7"

View file

@ -23,6 +23,7 @@ time = { version = "0.3.15", features = [
] } ] }
sha2 = "0.10.6" sha2 = "0.10.6"
path_macro = "1.0.0" path_macro = "1.0.0"
hex-literal = "0.3.4"
hex = { version = "0.4.3", features = ["serde"] } hex = { version = "0.4.3", features = ["serde"] }
temp-dir = "0.1.11" temp-dir = "0.1.11"
zip = { version = "0.6.4", default-features = false, features = [ zip = { version = "0.6.4", default-features = false, features = [
@ -49,5 +50,4 @@ rstest = "0.16.0"
poem = { version = "1.3.55", features = ["test"] } poem = { version = "1.3.55", features = ["test"] }
tokio-test = "0.4.2" tokio-test = "0.4.2"
temp_testdir = "0.2.3" temp_testdir = "0.2.3"
insta = { version = "1.17.1", features = ["ron"] } insta = { version = "1.17.1", features = ["ron", "redactions"] }
hex-literal = "0.3.4"

View file

@ -1,5 +1,6 @@
use std::{collections::BTreeMap, io::Cursor}; use std::io::Cursor;
use hex_literal::hex;
use poem::{ use poem::{
error::{Error, ResponseError}, error::{Error, ResponseError},
http::StatusCode, http::StatusCode,
@ -9,7 +10,7 @@ use poem::{
use poem_openapi::{ use poem_openapi::{
auth::ApiKey, auth::ApiKey,
param::{Path, Query}, param::{Path, Query},
payload::{Binary, Json}, payload::{Binary, Html, Json},
OpenApi, SecurityScheme, OpenApi, SecurityScheme,
}; };
@ -17,7 +18,7 @@ use crate::{
config::{Access, KeyCfg}, config::{Access, KeyCfg},
db, db,
model::*, model::*,
oai::DynParams, oai::{DynParams, FileResponse},
util, Talon, util, Talon,
}; };
@ -34,7 +35,8 @@ struct ApiKeyAuthorization(KeyCfg);
async fn api_key_checker(req: &Request, api_key: ApiKey) -> Option<KeyCfg> { async fn api_key_checker(req: &Request, api_key: ApiKey) -> Option<KeyCfg> {
let talon = req.data::<Talon>()?; let talon = req.data::<Talon>()?;
talon.cfg.keys.get(&api_key.key).cloned() let x = talon.cfg.keys.get(&api_key.key).cloned();
x
} }
impl ApiKeyAuthorization { impl ApiKeyAuthorization {
@ -57,12 +59,16 @@ enum ApiError {
NoAccess, NoAccess,
#[error("invalid fallback: {0}")] #[error("invalid fallback: {0}")]
InvalidFallback(String), InvalidFallback(String),
#[error("invalid archive type")]
InvalidArchiveType,
} }
impl ResponseError for ApiError { impl ResponseError for ApiError {
fn status(&self) -> StatusCode { fn status(&self) -> StatusCode {
match self { match self {
ApiError::InvalidSubdomain | ApiError::InvalidFallback(_) => StatusCode::BAD_REQUEST, ApiError::InvalidSubdomain
| ApiError::InvalidFallback(_)
| ApiError::InvalidArchiveType => StatusCode::BAD_REQUEST,
ApiError::NoAccess => StatusCode::UNAUTHORIZED, ApiError::NoAccess => StatusCode::UNAUTHORIZED,
} }
} }
@ -71,6 +77,19 @@ impl ResponseError for ApiError {
#[OpenApi] #[OpenApi]
#[allow(clippy::too_many_arguments)] #[allow(clippy::too_many_arguments)]
impl TalonApi { impl TalonApi {
/// Show some information about the API
#[oai(path = "/", method = "get", hidden)]
async fn root(&self) -> Html<&str> {
// TODO: use a pretty template for this
Html(
r#"<html>
<h1>Talon API</h1>
<p><a href="/api/swagger">Rumfingern</a></p>
<p><a href="/api/spec">OpenAPI specification</a></p>
</html>"#,
)
}
/// Get a website /// Get a website
#[oai(path = "/website/:subdomain", method = "get")] #[oai(path = "/website/:subdomain", method = "get")]
async fn website_get( async fn website_get(
@ -87,7 +106,7 @@ impl TalonApi {
/// Create a new website /// Create a new website
#[oai(path = "/website/:subdomain", method = "put")] #[oai(path = "/website/:subdomain", method = "put")]
async fn website_post( async fn website_create(
&self, &self,
auth: ApiKeyAuthorization, auth: ApiKeyAuthorization,
talon: Data<&Talon>, talon: Data<&Talon>,
@ -232,12 +251,12 @@ impl TalonApi {
talon: Data<&Talon>, talon: Data<&Talon>,
subdomain: Path<String>, subdomain: Path<String>,
version: Path<u32>, version: Path<u32>,
) -> Result<Json<Vec<String>>> { ) -> Result<Json<Vec<VersionFile>>> {
talon.db.version_exists(&subdomain, *version)?; talon.db.version_exists(&subdomain, *version)?;
talon talon
.db .db
.get_version_files(&subdomain, *version) .get_version_files(&subdomain, *version)
.map(|r| r.map(|f| f.0)) .map(|r| r.map(VersionFile::from))
.collect::<Result<Vec<_>, _>>() .collect::<Result<Vec<_>, _>>()
.map(Json) .map(Json)
.map_err(Error::from) .map_err(Error::from)
@ -258,51 +277,72 @@ impl TalonApi {
Ok(()) Ok(())
} }
/// Insert a new version into the database /// Upload a new version
fn insert_version( #[oai(path = "/website/:subdomain/upload", method = "post")]
talon: &Talon, async fn version_upload(
subdomain: &str, &self,
fallback: Option<String>, auth: ApiKeyAuthorization,
spa: bool, talon: Data<&Talon>,
mut version_data: BTreeMap<String, String>, subdomain: Path<String>,
) -> Result<u32> { /// Fallback page
///
/// The fallback page gets returned when the requested page does not exist
fallback: Query<Option<String>>,
/// SPA mode (return fallback page with OK status)
#[oai(default)]
spa: Query<bool>,
/// Associated version data
///
/// This is an arbitrary string map that can hold build information and other stuff
/// and will be displayed in the site info dialog.
version_data: DynParams,
/// Archive containing the website files.
///
/// Supported types: zip, tar.gz
data: Binary<Vec<u8>>,
) -> Result<()> {
auth.check_subdomain(&subdomain, Access::Upload)?;
let mut version_data = version_data.0;
version_data.remove("fallback"); version_data.remove("fallback");
version_data.remove("spa"); version_data.remove("spa");
let id = talon.db.insert_version( let version = talon.db.insert_version(
subdomain, &subdomain,
&db::model::Version { &db::model::Version {
data: version_data, data: version_data,
fallback, fallback: fallback.0.clone(),
spa, spa: spa.0,
..Default::default() ..Default::default()
}, },
)?; )?;
Ok(id)
if data.starts_with(&hex!("1f8b")) {
talon
.storage
.insert_tgz_archive(data.as_slice(), &subdomain, version)?;
} else if data.starts_with(&hex!("504b0304")) {
talon
.storage
.insert_zip_archive(Cursor::new(data.as_slice()), &subdomain, version)?;
} else {
return Err(ApiError::InvalidArchiveType.into());
} }
/// Set the given version as the most recent one
fn finalize_version(
talon: &Talon,
subdomain: &str,
version: u32,
fallback: Option<&str>,
) -> Result<()> {
// Validata fallback path // Validata fallback path
if let Some(fallback) = fallback { if let Some(fallback) = &fallback.0 {
if let Err(e) = if let Err(e) =
talon talon
.storage .storage
.get_file(subdomain, version, fallback, &Default::default()) .get_file(&subdomain, version, fallback, &Default::default())
{ {
// Remove the bad version // Remove the bad version
let _ = talon.db.delete_version(subdomain, version, false); let _ = talon.db.delete_version(&subdomain, version, false);
return Err(ApiError::InvalidFallback(e.to_string()).into()); return Err(ApiError::InvalidFallback(e.to_string()).into());
} }
} }
talon.db.update_website( talon.db.update_website(
subdomain, &subdomain,
db::model::WebsiteUpdate { db::model::WebsiteUpdate {
latest_version: Some(Some(version)), latest_version: Some(Some(version)),
..Default::default() ..Default::default()
@ -311,65 +351,25 @@ impl TalonApi {
Ok(()) Ok(())
} }
/// Upload a new version (.zip archive) /// Retrieve a file
#[oai(path = "/website/:subdomain/uploadZip", method = "post")] #[oai(path = "/file/:hash", method = "get")]
async fn version_upload_zip( async fn get_file(
&self, &self,
auth: ApiKeyAuthorization,
talon: Data<&Talon>, talon: Data<&Talon>,
subdomain: Path<String>, request: &Request,
/// Fallback page hash: Path<String>,
/// ) -> Result<FileResponse> {
/// The fallback page gets returned when the requested page does not exist let hash = hex::decode(hash.as_bytes()).map_err(|_| poem::http::StatusCode::BAD_REQUEST)?;
fallback: Query<Option<String>>, let gf = talon.storage.get_file_from_hash(
/// SPA mode (return fallback page with OK status) &hash,
#[oai(default)] Some(mime_guess::mime::APPLICATION_OCTET_STREAM),
spa: Query<bool>, None,
/// Associated version data request.headers(),
/// )?;
/// This is an arbitrary string map that can hold build information and other stuff let resp = talon
/// and will be displayed in the site info dialog.
version_data: DynParams,
/// zip archive with the website files
data: Binary<Vec<u8>>,
) -> Result<()> {
auth.check_subdomain(&subdomain, Access::Upload)?;
let version =
Self::insert_version(&talon, &subdomain, fallback.clone(), spa.0, version_data.0)?;
talon
.storage .storage
.insert_zip_archive(Cursor::new(data.as_slice()), &subdomain, version)?; .file_to_response(gf, request.headers(), true)
Self::finalize_version(&talon, &subdomain, version, fallback.as_deref()) .await?;
} Ok(FileResponse(resp))
/// Upload a new version (.tar.gz archive)
#[oai(path = "/website/:subdomain/uploadTgz", method = "post")]
async fn version_upload_tgz(
&self,
auth: ApiKeyAuthorization,
talon: Data<&Talon>,
subdomain: Path<String>,
/// Fallback page
///
/// The fallback page gets returned when the requested page does not exist
fallback: Query<Option<String>>,
/// SPA mode (return fallback page with OK status)
#[oai(default)]
spa: Query<bool>,
/// Associated version data
///
/// This is an arbitrary string map that can hold build information and other stuff
/// and will be displayed in the site info dialog.
version_data: DynParams,
/// tar.gz archive with the website files
data: Binary<Vec<u8>>,
) -> Result<()> {
auth.check_subdomain(&subdomain, Access::Upload)?;
let version =
Self::insert_version(&talon, &subdomain, fallback.clone(), spa.0, version_data.0)?;
talon
.storage
.insert_tgz_archive(data.as_slice(), &subdomain, version)?;
Self::finalize_version(&talon, &subdomain, version, fallback.as_deref())
} }
} }

View file

@ -313,7 +313,7 @@ impl Db {
} }
Err(_) => None, Err(_) => None,
}, },
None => todo!(), None => None,
})? })?
.and_then(|data| rmp_serde::from_slice::<Website>(&data).ok()); .and_then(|data| rmp_serde::from_slice::<Website>(&data).ok());

View file

@ -1,5 +1,6 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use hex::ToHex;
use poem_openapi::{Enum, Object}; use poem_openapi::{Enum, Object};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use time::OffsetDateTime; use time::OffsetDateTime;
@ -29,14 +30,16 @@ pub struct Website {
} }
/// Create a new website /// Create a new website
#[derive(Debug, Clone, Object)] #[derive(Debug, Clone, Object, Serialize, Deserialize)]
pub struct WebsiteNew { pub struct WebsiteNew {
/// Website name /// Website name
pub name: String, pub name: String,
/// Color of the page icon /// Color of the page icon
pub color: Option<u32>, pub color: Option<u32>,
/// Visibility of the page in the sidebar menu /// Visibility of the page in the sidebar menu
pub visibility: Option<Visibility>, #[serde(default)]
#[oai(default)]
pub visibility: Visibility,
/// Link to the source of the page /// Link to the source of the page
pub source_url: Option<String>, pub source_url: Option<String>,
/// Icon for the source link /// Icon for the source link
@ -46,7 +49,7 @@ pub struct WebsiteNew {
/// Update a website with the contained values /// Update a website with the contained values
/// ///
/// Values set to `None` remain unchanged. /// Values set to `None` remain unchanged.
#[derive(Debug, Clone, Object)] #[derive(Debug, Clone, Object, Serialize, Deserialize)]
pub struct WebsiteUpdate { pub struct WebsiteUpdate {
/// Website name /// Website name
pub name: Option<String>, pub name: Option<String>,
@ -75,6 +78,17 @@ pub struct Version {
pub data: BTreeMap<String, String>, pub data: BTreeMap<String, String>,
} }
/// Website file
#[derive(Debug, Clone, Object, Serialize, Deserialize)]
pub struct VersionFile {
/// File path
pub path: String,
/// File hash
pub hash: String,
/// MIME file type
pub mime: Option<String>,
}
#[derive( #[derive(
Debug, Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Enum, Serialize, Deserialize, Debug, Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Enum, Serialize, Deserialize,
)] )]
@ -120,7 +134,7 @@ impl From<WebsiteNew> for db::model::Website {
Self { Self {
name: value.name, name: value.name,
color: value.color, color: value.color,
visibility: value.visibility.unwrap_or_default(), visibility: value.visibility,
source_url: value.source_url, source_url: value.source_url,
source_icon: value.source_icon, source_icon: value.source_icon,
..Default::default() ..Default::default()
@ -151,3 +165,15 @@ impl From<(u32, db::model::Version)> for Version {
} }
} }
} }
impl From<(String, Vec<u8>)> for VersionFile {
fn from(value: (String, Vec<u8>)) -> Self {
Self {
mime: mime_guess::from_path(&value.0)
.first()
.map(|m| m.essence_str().to_owned()),
path: value.0,
hash: value.1.encode_hex(),
}
}
}

View file

@ -1,11 +1,16 @@
use std::collections::BTreeMap; use std::collections::BTreeMap;
use poem::{Request, RequestBody, Result}; use poem::{IntoResponse, Request, RequestBody, Response, Result};
use poem_openapi::{ use poem_openapi::{
ApiExtractor, ApiExtractorType, ExtractParamOptions, ApiExtractor, ApiExtractorType, ExtractParamOptions,
__private::UrlQuery, __private::UrlQuery,
registry::{MetaParamIn, MetaSchemaRef, Registry}, payload::Payload,
registry::{
MetaHeader, MetaMediaType, MetaParamIn, MetaResponse, MetaResponses, MetaSchemaRef,
Registry,
},
types::Type, types::Type,
ApiResponse,
}; };
pub struct DynParams(pub BTreeMap<String, String>); pub struct DynParams(pub BTreeMap<String, String>);
@ -51,3 +56,44 @@ impl<'a> ApiExtractor<'a> for DynParams {
)) ))
} }
} }
pub struct FileResponse(pub Response);
impl IntoResponse for FileResponse {
fn into_response(self) -> Response {
self.0
}
}
impl ApiResponse for FileResponse {
fn meta() -> MetaResponses {
MetaResponses {
responses: vec![MetaResponse {
description: "File content",
status: Some(200),
content: vec![MetaMediaType {
content_type: "application/octet-stream",
schema: poem_openapi::payload::Binary::<()>::schema_ref(),
}],
headers: vec![
MetaHeader {
name: "etag".to_owned(),
description: Some("File hash".to_owned()),
required: true,
deprecated: false,
schema: String::schema_ref(),
},
MetaHeader {
name: "last-modified".to_owned(),
description: Some("Date when the file was last modified".to_owned()),
required: true,
deprecated: false,
schema: String::schema_ref(),
},
],
}],
}
}
fn register(_registry: &mut Registry) {}
}

View file

@ -6,7 +6,7 @@ use poem::{
IntoResponse, Request, Response, Result, IntoResponse, Request, Response, Result,
}; };
use crate::{storage::StorageError, Talon}; use crate::{storage::StorageError, util, Talon};
#[derive(thiserror::Error, Debug)] #[derive(thiserror::Error, Debug)]
pub enum PageError { pub enum PageError {
@ -27,15 +27,17 @@ pub async fn page(request: &Request, talon: Data<&Talon>) -> Result<Response> {
let host = request let host = request
.header(header::HOST) .header(header::HOST)
.ok_or(PageError::InvalidSubdomain)?; .ok_or(PageError::InvalidSubdomain)?;
let subdomain = if host == talon.cfg.server.root_domain { let (subdomain, vid) =
"-" util::parse_host(host, &talon.cfg.server.root_domain).ok_or(PageError::InvalidSubdomain)?;
} else {
host.strip_suffix(&format!(".{}", talon.cfg.server.root_domain)) let vid = match vid {
.ok_or(PageError::InvalidSubdomain)? Some(vid) => vid,
None => {
let ws = talon.db.get_website(subdomain)?;
ws.latest_version.ok_or(PageError::NoVersion)?
}
}; };
let ws = talon.db.get_website(subdomain)?;
let vid = ws.latest_version.ok_or(PageError::NoVersion)?;
let (file, ok) = let (file, ok) =
match talon match talon
.storage .storage

View file

@ -75,10 +75,12 @@ pub enum StorageError {
InvalidFile(PathBuf), InvalidFile(PathBuf),
#[error("zip archive error: {0}")] #[error("zip archive error: {0}")]
Zip(#[from] zip::result::ZipError), Zip(#[from] zip::result::ZipError),
#[error("tar.gz archive error: {0}")]
Tgz(String),
#[error("page `{0}` not found")] #[error("page `{0}` not found")]
NotFound(String), NotFound(String),
#[error("file `{0}` of page `{1}` missing from storage")] #[error("file `{0}` missing from storage")]
MissingFile(String, String), MissingFile(String),
} }
impl ResponseError for StorageError { impl ResponseError for StorageError {
@ -86,7 +88,10 @@ impl ResponseError for StorageError {
match self { match self {
StorageError::Db(e) => e.status(), StorageError::Db(e) => e.status(),
StorageError::NotFound(_) => StatusCode::NOT_FOUND, StorageError::NotFound(_) => StatusCode::NOT_FOUND,
_ => StatusCode::INTERNAL_SERVER_ERROR, StorageError::InvalidFile(_) | StorageError::Zip(_) | StorageError::Tgz(_) => {
StatusCode::BAD_REQUEST
}
StorageError::Io(_) | StorageError::MissingFile(_) => StatusCode::INTERNAL_SERVER_ERROR,
} }
} }
} }
@ -242,7 +247,9 @@ impl Storage {
let temp = TempDir::with_prefix(TMPDIR_PREFIX)?; let temp = TempDir::with_prefix(TMPDIR_PREFIX)?;
let decoder = GzDecoder::new(reader); let decoder = GzDecoder::new(reader);
let mut archive = tar::Archive::new(decoder); let mut archive = tar::Archive::new(decoder);
archive.unpack(temp.path())?; archive
.unpack(temp.path())
.map_err(|e| StorageError::Tgz(e.to_string()))?;
let import_path = Self::fix_archive_path(temp.path())?; let import_path = Self::fix_archive_path(temp.path())?;
self.insert_dir(import_path, subdomain, version) self.insert_dir(import_path, subdomain, version)
} }
@ -357,8 +364,18 @@ impl Storage {
let mime = util::site_path_mime(&new_path); let mime = util::site_path_mime(&new_path);
self.get_file_from_hash(&hash, mime, rd_path, headers)
}
pub fn get_file_from_hash(
&self,
hash: &[u8],
mime: Option<Mime>,
rd_path: Option<String>,
headers: &HeaderMap,
) -> Result<GotFile> {
let algorithms = self.file_compressions( let algorithms = self.file_compressions(
&hash, hash,
mime.as_ref() mime.as_ref()
.map(|m| Self::is_compressible(m.essence_str())) .map(|m| Self::is_compressible(m.essence_str()))
.unwrap_or_default(), .unwrap_or_default(),
@ -368,15 +385,12 @@ impl Storage {
match alg { match alg {
Some(alg) => Ok(GotFile { Some(alg) => Ok(GotFile {
hash: hash.encode_hex(), hash: hash.encode_hex(),
file_path: self.file_path_compressed(&hash, alg), file_path: self.file_path_compressed(hash, alg),
encoding: alg.encoding(), encoding: alg.encoding(),
mime, mime,
rd_path, rd_path,
}), }),
None => Err(StorageError::MissingFile( None => Err(StorageError::MissingFile(hash.encode_hex())),
hash.encode_hex(),
new_path.into(),
)),
} }
} }
@ -430,6 +444,7 @@ impl Storage {
} }
} }
// HTML files are not precompressed and need to have UI code injected
if gf if gf
.mime .mime
.as_ref() .as_ref()

View file

@ -97,9 +97,11 @@ pub fn parse_accept_encoding(
/// Subdomains may only contain letters a-z, numbers 0-9 and dashes. /// Subdomains may only contain letters a-z, numbers 0-9 and dashes.
/// They must not start or end with dashes. /// They must not start or end with dashes.
/// ///
/// Forbidden subdomains: `xn` (punycode), `x` (reserved placeholder)
///
/// Special case: the root domain is described by subdomain `-` /// Special case: the root domain is described by subdomain `-`
pub fn validate_subdomain(subdomain: &str) -> bool { pub fn validate_subdomain(subdomain: &str) -> bool {
if subdomain.is_empty() || subdomain.len() > 200 { if subdomain.is_empty() || subdomain.len() > 200 || subdomain == "xn" || subdomain == "x" {
return false; return false;
} }
@ -116,6 +118,34 @@ pub fn validate_subdomain(subdomain: &str) -> bool {
.all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-') .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
} }
/// Parse the given hostname
///
/// Returns the page subdomain and optional version ID
///
/// # Examples
/// `example.com` (root domain)
///
/// `x--v1.example.com` (root domain + version id)
///
/// `talon.example.com` (subdomain)
///
/// `talon--v1.example.com` (subdomain + version id)
pub fn parse_host<'a>(host: &'a str, root_domain: &str) -> Option<(&'a str, Option<u32>)> {
if host == root_domain {
Some(("-", None))
} else {
let subdomain = host.strip_suffix(&format!(".{}", root_domain))?;
if let Some((subdomain, vstr)) = subdomain.split_once("--") {
let version = vstr.strip_prefix('v')?.parse().ok()?;
let subdomain = if subdomain == "x" { "-" } else { subdomain };
Some((subdomain, Some(version)))
} else {
Some((subdomain, None))
}
}
}
pub fn create_dir_ne<P: AsRef<Path>>(path: P) -> Result<(), std::io::Error> { pub fn create_dir_ne<P: AsRef<Path>>(path: P) -> Result<(), std::io::Error> {
let path = path.as_ref(); let path = path.as_ref();
if !path.is_dir() { if !path.is_dir() {

View file

@ -43,6 +43,10 @@ pub const HASH_SPA_INDEX: [u8; 32] =
pub const HASH_SPA_FALLBACK: [u8; 32] = pub const HASH_SPA_FALLBACK: [u8; 32] =
hex!("4ee0d3f7522f620a2a69b39b7443f8fe65029e1324cefaf797b8cad2b223cf7b"); hex!("4ee0d3f7522f620a2a69b39b7443f8fe65029e1324cefaf797b8cad2b223cf7b");
pub const API_KEY_ROOT: &str = "c32ff286c8ac1c3102625badf38ffd251ae0c4a56079d8ba490f320af63f1f47";
pub const API_KEY_2: &str = "21bdac19ffd22870d561b1d55b35eddd9029497107edb7b926aa3e7856bb409b";
// pub const API_KEY_3: &str = "04e99561e3824f387a217d141d2a3b46375de6864afbedf9c9a2cc102bc946a4";
pub struct DbTest { pub struct DbTest {
db: Db, db: Db,
_temp: TempDir, _temp: TempDir,
@ -261,6 +265,11 @@ impl Deref for TalonTest {
#[fixture] #[fixture]
pub fn tln() -> TalonTest { pub fn tln() -> TalonTest {
let temp = temp_testdir::TempDir::default(); let temp = temp_testdir::TempDir::default();
std::fs::copy(
path!("tests" / "testfiles" / "config" / "config_test.toml"),
path!(temp / "config.toml"),
)
.unwrap();
let talon = Talon::new(&temp).unwrap(); let talon = Talon::new(&temp).unwrap();
insert_websites(&talon.db); insert_websites(&talon.db);

View file

@ -0,0 +1,41 @@
---
source: tests/tests.rs
expression: files
---
[
VersionFile(
path: "assets/example.txt",
hash: "bae6bdae8097c24f9a99028e04bfc8d5e0a0c318955316db0e7b955def9c1dbb",
mime: Some("text/plain"),
),
VersionFile(
path: "assets/image.jpg",
hash: "901d291a47a8a9b55c06f84e5e5f82fd2dcee65cac1406d6e878b805d45c1e93",
mime: Some("image/jpeg"),
),
VersionFile(
path: "assets/style.css",
hash: "356f131c825fbf604797c7e9c85352549d81db8af91fee834016d075110af026",
mime: Some("text/css"),
),
VersionFile(
path: "assets/test.js",
hash: "b6ed35f5ae339a35a8babb11a91ff90c1a62ef250d30fa98e59500e8dbb896fa",
mime: Some("application/javascript"),
),
VersionFile(
path: "assets/thetadev-blue.svg",
hash: "9c37a2cb1230a9cbe7911d34404d4fb03b27552e56b2173683cf9fc52be7bc99",
mime: Some("image/svg+xml"),
),
VersionFile(
path: "index.html",
hash: "a44816e6c3b650bdf88e6532659ba07ef187c2113ae311da9709e056aec8eadb",
mime: Some("text/html"),
),
VersionFile(
path: "logo.png",
hash: "9f7e7971b4bfdb75429e534dea461ed90340886925078cda252cada9aa0e25f7",
mime: Some("image/png"),
),
]

View file

@ -0,0 +1,22 @@
---
source: tests/tests.rs
expression: versions
---
[
Version(
id: 1,
created_at: "2023-02-18T16:30:00Z",
data: {
"Deployed by": "https://github.com/Theta-Dev/Talon/actions/runs/1352014628",
"Version": "v0.1.0",
},
),
Version(
id: 2,
created_at: "2023-02-18T16:52:00Z",
data: {
"Deployed by": "https://github.com/Theta-Dev/Talon/actions/runs/1354755231",
"Version": "v0.1.1",
},
),
]

View file

@ -0,0 +1,36 @@
---
source: tests/tests.rs
expression: websites
---
[
Website(
subdomain: "-",
name: "ThetaDev",
created_at: "2023-02-18T16:30:00Z",
latest_version: Some(2),
color: Some(2068974),
visibility: featured,
source_url: None,
source_icon: None,
),
Website(
subdomain: "rustypipe",
name: "RustyPipe",
created_at: "2023-02-20T18:30:00Z",
latest_version: Some(1),
color: Some(7943647),
visibility: featured,
source_url: Some("https://code.thetadev.de/ThetaDev/rustypipe"),
source_icon: Some(gitea),
),
Website(
subdomain: "spotify-gender-ex",
name: "Spotify-Gender-Ex",
created_at: "2023-02-18T16:30:00Z",
latest_version: Some(1),
color: Some(1947988),
visibility: featured,
source_url: Some("https://github.com/Theta-Dev/Spotify-Gender-Ex"),
source_icon: Some(github),
),
]

View file

@ -0,0 +1,46 @@
---
source: tests/tests.rs
expression: websites
---
[
Website(
subdomain: "-",
name: "ThetaDev",
created_at: "2023-02-18T16:30:00Z",
latest_version: Some(2),
color: Some(2068974),
visibility: featured,
source_url: None,
source_icon: None,
),
Website(
subdomain: "rustypipe",
name: "RustyPipe",
created_at: "2023-02-20T18:30:00Z",
latest_version: Some(1),
color: Some(7943647),
visibility: featured,
source_url: Some("https://code.thetadev.de/ThetaDev/rustypipe"),
source_icon: Some(gitea),
),
Website(
subdomain: "spa",
name: "SvelteKit SPA",
created_at: "2023-03-03T22:00:00Z",
latest_version: Some(1),
color: Some(16727552),
visibility: hidden,
source_url: None,
source_icon: None,
),
Website(
subdomain: "spotify-gender-ex",
name: "Spotify-Gender-Ex",
created_at: "2023-02-18T16:30:00Z",
latest_version: Some(1),
color: Some(1947988),
visibility: featured,
source_url: Some("https://github.com/Theta-Dev/Spotify-Gender-Ex"),
source_icon: Some(github),
),
]

View file

@ -0,0 +1,18 @@
# Config file for running tests
[keys.c32ff286c8ac1c3102625badf38ffd251ae0c4a56079d8ba490f320af63f1f47]
domains = "*"
upload = true
modify = true
[keys.21bdac19ffd22870d561b1d55b35eddd9029497107edb7b926aa3e7856bb409b]
domains = ["spotify-gender-ex", "rustypipe", "test"]
upload = true
[keys.04e99561e3824f387a217d141d2a3b46375de6864afbedf9c9a2cc102bc946a4]
domains = "/^talon-\\d+/"
upload = true
modify = true
[keys.48691ad9f42bb12e61e259b5e90dc941a293cfae11af18c9e6557f92557f0086]
domains = "*"

View file

@ -587,6 +587,7 @@ mod page {
#[case::rustypipe2("rustypipe", "/page2/index.html", &HASH_3_1_PAGE2, "text/html")] #[case::rustypipe2("rustypipe", "/page2/index.html", &HASH_3_1_PAGE2, "text/html")]
#[case::spa_index("spa", "/", &HASH_SPA_INDEX, "text/html")] #[case::spa_index("spa", "/", &HASH_SPA_INDEX, "text/html")]
#[case::spa_fallback("spa", "/user/2", &HASH_SPA_FALLBACK, "text/html")] #[case::spa_fallback("spa", "/user/2", &HASH_SPA_FALLBACK, "text/html")]
#[case::version("x--v1", "/", &HASH_1_1_INDEX, "text/html")]
fn page( fn page(
tln: TalonTest, tln: TalonTest,
#[case] subdomain: &str, #[case] subdomain: &str,
@ -684,3 +685,495 @@ mod page {
} }
} }
} }
mod api {
use hex::ToHex;
use hex_literal::hex;
use poem::{
http::{header, Method, StatusCode},
test::TestClient,
};
use talon::model::*;
use time::macros::datetime;
use super::*;
#[rstest]
fn website_get(tln: TalonTest) {
let resp = tokio_test::block_on(
TestClient::new(tln.endpoint())
.get("http://talon.localhost:3000/api/website/spotify-gender-ex")
.header(header::HOST, "talon.localhost:3000")
.data(tln.clone())
.send(),
);
resp.assert_status_is_ok();
tokio_test::block_on(resp.assert_json(Website {
subdomain: "spotify-gender-ex".to_owned(),
name: "Spotify-Gender-Ex".to_owned(),
created_at: datetime!(2023-02-18 16:30 +0),
latest_version: Some(1),
color: Some(1947988),
visibility: Visibility::Featured,
source_url: Some("https://github.com/Theta-Dev/Spotify-Gender-Ex".to_owned()),
source_icon: Some(SourceIcon::Github),
}));
}
#[rstest]
fn website_get_404(tln: TalonTest) {
let resp = tokio_test::block_on(
TestClient::new(tln.endpoint())
.get("http://talon.localhost:3000/api/website/foo")
.header(header::HOST, "talon.localhost:3000")
.data(tln.clone())
.send(),
);
resp.assert_status(StatusCode::NOT_FOUND);
}
#[rstest]
fn website_create(tln: TalonTest) {
let resp = tokio_test::block_on(
TestClient::new(tln.endpoint())
.put("http://talon.localhost:3000/api/website/test")
.header(header::HOST, "talon.localhost:3000")
.header("x-api-key", API_KEY_ROOT)
.data(tln.clone())
.body_json(&WebsiteNew {
name: "Test".to_owned(),
color: Some(1000),
visibility: Visibility::Searchable,
source_icon: Some(SourceIcon::Git),
source_url: Some("example.com".to_owned()),
})
.send(),
);
resp.assert_status_is_ok();
let ws = tln.db.get_website("test").unwrap();
insta::assert_ron_snapshot!(ws, {".created_at" => "[date]"}, @r###"
Website(
name: "Test",
created_at: "[date]",
latest_version: None,
color: Some(1000),
visibility: searchable,
source_url: Some("example.com"),
source_icon: Some(git),
vid_count: 0,
)
"###);
}
#[rstest]
fn website_create_conflict(tln: TalonTest) {
let resp = tokio_test::block_on(
TestClient::new(tln.endpoint())
.put("http://talon.localhost:3000/api/website/-")
.header(header::HOST, "talon.localhost:3000")
.header("x-api-key", API_KEY_ROOT)
.data(tln.clone())
.body_json(&WebsiteNew {
name: "Test".to_owned(),
color: Some(1000),
visibility: Visibility::Searchable,
source_icon: Some(SourceIcon::Git),
source_url: Some("example.com".to_owned()),
})
.send(),
);
resp.assert_status(StatusCode::CONFLICT);
}
#[rstest]
fn website_update(tln: TalonTest) {
let resp = tokio_test::block_on(
TestClient::new(tln.endpoint())
.patch("http://talon.localhost:3000/api/website/-")
.header(header::HOST, "talon.localhost:3000")
.header("x-api-key", API_KEY_ROOT)
.data(tln.clone())
.body_json(&WebsiteUpdate {
name: Some("Test".to_owned()),
color: Some(Some(1000)),
visibility: Some(Visibility::Searchable),
source_icon: Some(Some(SourceIcon::Git)),
source_url: Some(Some("example.com".to_owned())),
})
.send(),
);
resp.assert_status_is_ok();
let ws = tln.db.get_website("-").unwrap();
insta::assert_ron_snapshot!(ws, @r###"
Website(
name: "Test",
created_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0),
latest_version: Some(2),
color: Some(1000),
visibility: searchable,
source_url: Some("example.com"),
source_icon: Some(git),
vid_count: 2,
)
"###);
}
#[rstest]
fn website_update_404(tln: TalonTest) {
let resp = tokio_test::block_on(
TestClient::new(tln.endpoint())
.patch("http://talon.localhost:3000/api/website/foo")
.header(header::HOST, "talon.localhost:3000")
.header("x-api-key", API_KEY_ROOT)
.data(tln.clone())
.body_json(&WebsiteUpdate {
name: Some("Test".to_owned()),
color: Some(Some(1000)),
visibility: Some(Visibility::Searchable),
source_icon: Some(Some(SourceIcon::Git)),
source_url: Some(Some("example.com".to_owned())),
})
.send(),
);
resp.assert_status(StatusCode::NOT_FOUND);
}
#[rstest]
fn website_delete(tln: TalonTest) {
let resp = tokio_test::block_on(
TestClient::new(tln.endpoint())
.delete("http://talon.localhost:3000/api/website/-")
.header(header::HOST, "talon.localhost:3000")
.header("x-api-key", API_KEY_ROOT)
.data(tln.clone())
.send(),
);
resp.assert_status_is_ok();
let err = tln.db.get_website("-").unwrap_err();
assert!(matches!(err, DbError::NotExists(_, _)));
}
#[rstest]
fn website_delete_404(tln: TalonTest) {
let resp = tokio_test::block_on(
TestClient::new(tln.endpoint())
.delete("http://talon.localhost:3000/api/website/foo")
.header(header::HOST, "talon.localhost:3000")
.header("x-api-key", API_KEY_ROOT)
.data(tln.clone())
.send(),
);
resp.assert_status(StatusCode::NOT_FOUND);
}
#[rstest]
fn websites_get(tln: TalonTest) {
let resp = tokio_test::block_on(
TestClient::new(tln.endpoint())
.get("http://talon.localhost:3000/api/websites")
.header(header::HOST, "talon.localhost:3000")
.data(tln.clone())
.send(),
);
resp.assert_status(StatusCode::OK);
let websites =
tokio_test::block_on(resp.0.into_body().into_json::<Vec<Website>>()).unwrap();
insta::assert_ron_snapshot!(websites);
}
#[rstest]
fn websites_get_all(tln: TalonTest) {
let resp = tokio_test::block_on(
TestClient::new(tln.endpoint())
.get("http://talon.localhost:3000/api/websitesAll")
.header(header::HOST, "talon.localhost:3000")
.header("x-api-key", API_KEY_ROOT)
.data(tln.clone())
.send(),
);
resp.assert_status(StatusCode::OK);
let websites =
tokio_test::block_on(resp.0.into_body().into_json::<Vec<Website>>()).unwrap();
insta::assert_ron_snapshot!(websites);
}
/// `websitesAll` should only return hidden websites if the user can access them
#[rstest]
fn websites_get_all_noperm(tln: TalonTest) {
let resp = tokio_test::block_on(
TestClient::new(tln.endpoint())
.get("http://talon.localhost:3000/api/websitesAll")
.header(header::HOST, "talon.localhost:3000")
.header("x-api-key", API_KEY_2)
.data(tln.clone())
.send(),
);
resp.assert_status(StatusCode::OK);
let websites =
tokio_test::block_on(resp.0.into_body().into_json::<Vec<Website>>()).unwrap();
insta::assert_ron_snapshot!("websites_get", websites);
}
#[rstest]
fn website_versions(tln: TalonTest) {
let resp = tokio_test::block_on(
TestClient::new(tln.endpoint())
.get("http://talon.localhost:3000/api/website/-/versions")
.header(header::HOST, "talon.localhost:3000")
.data(tln.clone())
.send(),
);
resp.assert_status(StatusCode::OK);
let versions =
tokio_test::block_on(resp.0.into_body().into_json::<Vec<Version>>()).unwrap();
insta::assert_ron_snapshot!(versions);
}
#[rstest]
fn website_versions_404(tln: TalonTest) {
let resp = tokio_test::block_on(
TestClient::new(tln.endpoint())
.get("http://talon.localhost:3000/api/website/foo/versions")
.header(header::HOST, "talon.localhost:3000")
.data(tln.clone())
.send(),
);
resp.assert_status(StatusCode::NOT_FOUND);
}
#[rstest]
fn version_files(tln: TalonTest) {
let resp = tokio_test::block_on(
TestClient::new(tln.endpoint())
.get("http://talon.localhost:3000/api/website/-/version/2/files")
.header(header::HOST, "talon.localhost:3000")
.data(tln.clone())
.send(),
);
resp.assert_status(StatusCode::OK);
let files =
tokio_test::block_on(resp.0.into_body().into_json::<Vec<VersionFile>>()).unwrap();
insta::assert_ron_snapshot!(files);
}
#[rstest]
fn version_files_404(tln: TalonTest) {
let resp = tokio_test::block_on(
TestClient::new(tln.endpoint())
.get("http://talon.localhost:3000/api/website/-/version/3/files")
.header(header::HOST, "talon.localhost:3000")
.data(tln.clone())
.send(),
);
resp.assert_status(StatusCode::NOT_FOUND);
}
#[rstest]
fn version_delete(tln: TalonTest) {
let resp = tokio_test::block_on(
TestClient::new(tln.endpoint())
.delete("http://talon.localhost:3000/api/website/-/version/2")
.header(header::HOST, "talon.localhost:3000")
.header("x-api-key", API_KEY_ROOT)
.data(tln.clone())
.send(),
);
resp.assert_status_is_ok();
let err = tln.db.get_version("-", 2).unwrap_err();
assert!(matches!(err, DbError::NotExists(_, _)));
let ws = tln.db.get_website("-").unwrap();
assert_eq!(ws.latest_version, Some(1));
let resp = tokio_test::block_on(
TestClient::new(tln.endpoint())
.delete("http://talon.localhost:3000/api/website/-/version/1")
.header(header::HOST, "talon.localhost:3000")
.header("x-api-key", API_KEY_ROOT)
.data(tln.clone())
.send(),
);
resp.assert_status_is_ok();
let err = tln.db.get_version("-", 1).unwrap_err();
assert!(matches!(err, DbError::NotExists(_, _)));
let ws = tln.db.get_website("-").unwrap();
assert_eq!(ws.latest_version, None);
}
#[rstest]
fn version_delete_404(tln: TalonTest) {
let resp = tokio_test::block_on(
TestClient::new(tln.endpoint())
.delete("http://talon.localhost:3000/api/website/-/version/3")
.header(header::HOST, "talon.localhost:3000")
.header("x-api-key", API_KEY_ROOT)
.data(tln.clone())
.send(),
);
resp.assert_status(StatusCode::NOT_FOUND);
}
#[rstest]
fn version_upload_zip(tln: TalonTest) {
let path = path!("tests" / "testfiles" / "archive" / "ThetaDev1.zip");
let archive = std::fs::read(path).unwrap();
let resp = tokio_test::block_on(
TestClient::new(tln.endpoint())
.post("http://talon.localhost:3000/api/website/rustypipe/upload?version=1.2.3&hello=world")
.header(header::HOST, "talon.localhost:3000")
.header(header::CONTENT_TYPE, "application/octet-stream")
.header("x-api-key", API_KEY_ROOT)
.data(tln.clone())
.body(archive)
.send(),
);
resp.assert_status_is_ok();
let ws = tln.db.get_website("rustypipe").unwrap();
assert_eq!(ws.latest_version, Some(2));
let version = tln.db.get_version("rustypipe", 2).unwrap();
insta::assert_ron_snapshot!(version, {".created_at" => "[date]"}, @r###"
Version(
created_at: "[date]",
data: {
"hello": "world",
"version": "1.2.3",
},
fallback: None,
spa: false,
)
"###);
let files = tln
.db
.get_version_files("rustypipe", 2)
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert_eq!(files.len(), 7);
}
#[rstest]
fn version_upload_tgz(tln: TalonTest) {
let path = path!("tests" / "testfiles" / "archive" / "spa.tar.gz");
let archive = std::fs::read(path).unwrap();
let resp = tokio_test::block_on(
TestClient::new(tln.endpoint())
.post("http://talon.localhost:3000/api/website/rustypipe/upload?spa=true&fallback=200.html&version=1.2.3")
.header(header::HOST, "talon.localhost:3000")
.header(header::CONTENT_TYPE, "application/octet-stream")
.header("x-api-key", API_KEY_ROOT)
.data(tln.clone())
.body(archive)
.send(),
);
resp.assert_status_is_ok();
let ws = tln.db.get_website("rustypipe").unwrap();
assert_eq!(ws.latest_version, Some(2));
let version = tln.db.get_version("rustypipe", 2).unwrap();
insta::assert_ron_snapshot!(version, {".created_at" => "[date]"}, @r###"
Version(
created_at: "[date]",
data: {
"version": "1.2.3",
},
fallback: Some("200.html"),
spa: true,
)
"###);
let files = tln
.db
.get_version_files("rustypipe", 2)
.collect::<Result<Vec<_>, _>>()
.unwrap();
assert_eq!(files.len(), 23);
}
#[rstest]
fn version_upload_fallback_not_found(tln: TalonTest) {
let path = path!("tests" / "testfiles" / "archive" / "ThetaDev1.zip");
let archive = std::fs::read(path).unwrap();
let resp = tokio_test::block_on(
TestClient::new(tln.endpoint())
.post("http://talon.localhost:3000/api/website/rustypipe/upload?spa=true&fallback=foo.html")
.header(header::HOST, "talon.localhost:3000")
.header(header::CONTENT_TYPE, "application/octet-stream")
.header("x-api-key", API_KEY_ROOT)
.data(tln.clone())
.body(archive)
.send(),
);
resp.assert_status(StatusCode::BAD_REQUEST);
}
#[rstest]
#[case::no_archive(&hex!("badeaffe"))]
#[case::bad_zip(&hex!("504b0304badeaffe"))]
#[case::bad_tgz(&hex!("1f8bbadeaffe"))]
fn version_upload_invalid(tln: TalonTest, #[case] data: &[u8]) {
let resp = tokio_test::block_on(
TestClient::new(tln.endpoint())
.post("http://talon.localhost:3000/api/website/rustypipe/upload?spa=true&fallback=foo.html")
.header(header::HOST, "talon.localhost:3000")
.header(header::CONTENT_TYPE, "application/octet-stream")
.header("x-api-key", API_KEY_ROOT)
.data(tln.clone())
.body(data.to_vec())
.send(),
);
resp.assert_status(StatusCode::BAD_REQUEST);
}
#[rstest]
fn file(tln: TalonTest) {
let resp = tokio_test::block_on(
TestClient::new(tln.endpoint())
.get(format!(
"http://talon.localhost:3000/api/file/{}",
HASH_1_1_INDEX.encode_hex::<String>()
))
.header(header::HOST, "talon.localhost:3000")
.data(tln.clone())
.send(),
);
resp.assert_status_is_ok();
let expect =
std::fs::read_to_string(path!("tests" / "testfiles" / "ThetaDev0" / "index.html"))
.unwrap();
tokio_test::block_on(resp.assert_text(expect));
}
#[rstest]
#[case::website_create("website/test", Method::PUT)]
#[case::website_update("website/test", Method::PATCH)]
#[case::website_delete("website/test", Method::DELETE)]
#[case::websites_all("websitesAll", Method::GET)]
#[case::version_delete("website/test/version/1", Method::DELETE)]
#[case::version_upload("website/test/upload", Method::POST)]
fn unauthorized(tln: TalonTest, #[case] endpoint: &str, #[case] method: Method) {
let resp = tokio_test::block_on(
TestClient::new(tln.endpoint())
.request(
method,
format!("http://talon.localhost:3000/api/{endpoint}"),
)
.header(header::HOST, "talon.localhost:3000")
.data(tln.clone())
.send(),
);
resp.assert_status(StatusCode::UNAUTHORIZED);
}
}