diff --git a/Cargo.lock b/Cargo.lock index cad3e4a..519d51d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -936,8 +936,6 @@ dependencies = [ "console", "lazy_static", "linked-hash-map", - "pest", - "pest_derive", "ron", "serde", "similar", @@ -1242,50 +1240,6 @@ version = "2.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" 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]] name = "pin-project" version = "0.4.30" @@ -2160,12 +2114,6 @@ version = "1.16.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "497961ef93d974e23eb6f433eb5fe1b7930b659f06d12dec6fc44a8f554c0bba" -[[package]] -name = "ucd-trie" -version = "0.1.5" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e79c4d996edb816c91e4308506774452e55e95c3c9de07b6729e17e15a5ef81" - [[package]] name = "uncased" version = "0.9.7" diff --git a/Cargo.toml b/Cargo.toml index bfd918f..543fa6e 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,6 @@ time = { version = "0.3.15", features = [ ] } sha2 = "0.10.6" path_macro = "1.0.0" -hex-literal = "0.3.4" hex = { version = "0.4.3", features = ["serde"] } temp-dir = "0.1.11" zip = { version = "0.6.4", default-features = false, features = [ @@ -50,4 +49,5 @@ rstest = "0.16.0" poem = { version = "1.3.55", features = ["test"] } tokio-test = "0.4.2" temp_testdir = "0.2.3" -insta = { version = "1.17.1", features = ["ron", "redactions"] } +insta = { version = "1.17.1", features = ["ron"] } +hex-literal = "0.3.4" diff --git a/src/api.rs b/src/api.rs index d0020aa..f99886a 100644 --- a/src/api.rs +++ b/src/api.rs @@ -1,6 +1,5 @@ -use std::io::Cursor; +use std::{collections::BTreeMap, io::Cursor}; -use hex_literal::hex; use poem::{ error::{Error, ResponseError}, http::StatusCode, @@ -10,7 +9,7 @@ use poem::{ use poem_openapi::{ auth::ApiKey, param::{Path, Query}, - payload::{Binary, Html, Json}, + payload::{Binary, Json}, OpenApi, SecurityScheme, }; @@ -18,7 +17,7 @@ use crate::{ config::{Access, KeyCfg}, db, model::*, - oai::{DynParams, FileResponse}, + oai::DynParams, util, Talon, }; @@ -35,8 +34,7 @@ struct ApiKeyAuthorization(KeyCfg); async fn api_key_checker(req: &Request, api_key: ApiKey) -> Option { let talon = req.data::()?; - let x = talon.cfg.keys.get(&api_key.key).cloned(); - x + talon.cfg.keys.get(&api_key.key).cloned() } impl ApiKeyAuthorization { @@ -59,16 +57,12 @@ enum ApiError { NoAccess, #[error("invalid fallback: {0}")] InvalidFallback(String), - #[error("invalid archive type")] - InvalidArchiveType, } impl ResponseError for ApiError { fn status(&self) -> StatusCode { match self { - ApiError::InvalidSubdomain - | ApiError::InvalidFallback(_) - | ApiError::InvalidArchiveType => StatusCode::BAD_REQUEST, + ApiError::InvalidSubdomain | ApiError::InvalidFallback(_) => StatusCode::BAD_REQUEST, ApiError::NoAccess => StatusCode::UNAUTHORIZED, } } @@ -77,19 +71,6 @@ impl ResponseError for ApiError { #[OpenApi] #[allow(clippy::too_many_arguments)] 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#" -

Talon API

-

Rumfingern

-

OpenAPI specification

-"#, - ) - } - /// Get a website #[oai(path = "/website/:subdomain", method = "get")] async fn website_get( @@ -106,7 +87,7 @@ impl TalonApi { /// Create a new website #[oai(path = "/website/:subdomain", method = "put")] - async fn website_create( + async fn website_post( &self, auth: ApiKeyAuthorization, talon: Data<&Talon>, @@ -251,12 +232,12 @@ impl TalonApi { talon: Data<&Talon>, subdomain: Path, version: Path, - ) -> Result>> { + ) -> Result>> { talon.db.version_exists(&subdomain, *version)?; talon .db .get_version_files(&subdomain, *version) - .map(|r| r.map(VersionFile::from)) + .map(|r| r.map(|f| f.0)) .collect::, _>>() .map(Json) .map_err(Error::from) @@ -277,9 +258,62 @@ impl TalonApi { Ok(()) } - /// Upload a new version - #[oai(path = "/website/:subdomain/upload", method = "post")] - async fn version_upload( + /// Insert a new version into the database + fn insert_version( + talon: &Talon, + subdomain: &str, + fallback: Option, + spa: bool, + mut version_data: BTreeMap, + ) -> Result { + version_data.remove("fallback"); + version_data.remove("spa"); + + let id = talon.db.insert_version( + subdomain, + &db::model::Version { + data: version_data, + fallback, + spa, + ..Default::default() + }, + )?; + Ok(id) + } + + /// 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 + if let Some(fallback) = fallback { + if let Err(e) = + talon + .storage + .get_file(subdomain, version, fallback, &Default::default()) + { + // Remove the bad version + let _ = talon.db.delete_version(subdomain, version, false); + return Err(ApiError::InvalidFallback(e.to_string()).into()); + } + } + + talon.db.update_website( + subdomain, + db::model::WebsiteUpdate { + latest_version: Some(Some(version)), + ..Default::default() + }, + )?; + Ok(()) + } + + /// Upload a new version (.zip archive) + #[oai(path = "/website/:subdomain/uploadZip", method = "post")] + async fn version_upload_zip( &self, auth: ApiKeyAuthorization, talon: Data<&Talon>, @@ -296,80 +330,46 @@ impl TalonApi { /// 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 + /// zip archive with the website files data: Binary>, ) -> Result<()> { auth.check_subdomain(&subdomain, Access::Upload)?; - let mut version_data = version_data.0; - version_data.remove("fallback"); - version_data.remove("spa"); - - let version = talon.db.insert_version( - &subdomain, - &db::model::Version { - data: version_data, - fallback: fallback.0.clone(), - spa: spa.0, - ..Default::default() - }, - )?; - - 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()); - } - - // Validata fallback path - if let Some(fallback) = &fallback.0 { - if let Err(e) = - talon - .storage - .get_file(&subdomain, version, fallback, &Default::default()) - { - // Remove the bad version - let _ = talon.db.delete_version(&subdomain, version, false); - return Err(ApiError::InvalidFallback(e.to_string()).into()); - } - } - - talon.db.update_website( - &subdomain, - db::model::WebsiteUpdate { - latest_version: Some(Some(version)), - ..Default::default() - }, - )?; - Ok(()) + let version = + Self::insert_version(&talon, &subdomain, fallback.clone(), spa.0, version_data.0)?; + talon + .storage + .insert_zip_archive(Cursor::new(data.as_slice()), &subdomain, version)?; + Self::finalize_version(&talon, &subdomain, version, fallback.as_deref()) } - /// Retrieve a file - #[oai(path = "/file/:hash", method = "get")] - async fn get_file( + /// 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>, - request: &Request, - hash: Path, - ) -> Result { - let hash = hex::decode(hash.as_bytes()).map_err(|_| poem::http::StatusCode::BAD_REQUEST)?; - let gf = talon.storage.get_file_from_hash( - &hash, - Some(mime_guess::mime::APPLICATION_OCTET_STREAM), - None, - request.headers(), - )?; - let resp = talon + subdomain: Path, + /// Fallback page + /// + /// The fallback page gets returned when the requested page does not exist + fallback: Query>, + /// SPA mode (return fallback page with OK status) + #[oai(default)] + spa: Query, + /// 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>, + ) -> Result<()> { + auth.check_subdomain(&subdomain, Access::Upload)?; + let version = + Self::insert_version(&talon, &subdomain, fallback.clone(), spa.0, version_data.0)?; + talon .storage - .file_to_response(gf, request.headers(), true) - .await?; - Ok(FileResponse(resp)) + .insert_tgz_archive(data.as_slice(), &subdomain, version)?; + Self::finalize_version(&talon, &subdomain, version, fallback.as_deref()) } } diff --git a/src/db/mod.rs b/src/db/mod.rs index 4463d45..80df335 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -313,7 +313,7 @@ impl Db { } Err(_) => None, }, - None => None, + None => todo!(), })? .and_then(|data| rmp_serde::from_slice::(&data).ok()); diff --git a/src/model.rs b/src/model.rs index dc17870..00d4c76 100644 --- a/src/model.rs +++ b/src/model.rs @@ -1,6 +1,5 @@ use std::collections::BTreeMap; -use hex::ToHex; use poem_openapi::{Enum, Object}; use serde::{Deserialize, Serialize}; use time::OffsetDateTime; @@ -30,16 +29,14 @@ pub struct Website { } /// Create a new website -#[derive(Debug, Clone, Object, Serialize, Deserialize)] +#[derive(Debug, Clone, Object)] pub struct WebsiteNew { /// Website name pub name: String, /// Color of the page icon pub color: Option, /// Visibility of the page in the sidebar menu - #[serde(default)] - #[oai(default)] - pub visibility: Visibility, + pub visibility: Option, /// Link to the source of the page pub source_url: Option, /// Icon for the source link @@ -49,7 +46,7 @@ pub struct WebsiteNew { /// Update a website with the contained values /// /// Values set to `None` remain unchanged. -#[derive(Debug, Clone, Object, Serialize, Deserialize)] +#[derive(Debug, Clone, Object)] pub struct WebsiteUpdate { /// Website name pub name: Option, @@ -78,17 +75,6 @@ pub struct Version { pub data: BTreeMap, } -/// 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, -} - #[derive( Debug, Default, Copy, Clone, PartialEq, Eq, PartialOrd, Ord, Enum, Serialize, Deserialize, )] @@ -134,7 +120,7 @@ impl From for db::model::Website { Self { name: value.name, color: value.color, - visibility: value.visibility, + visibility: value.visibility.unwrap_or_default(), source_url: value.source_url, source_icon: value.source_icon, ..Default::default() @@ -165,15 +151,3 @@ impl From<(u32, db::model::Version)> for Version { } } } - -impl From<(String, Vec)> for VersionFile { - fn from(value: (String, Vec)) -> 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(), - } - } -} diff --git a/src/oai.rs b/src/oai.rs index 64a79da..c637c9d 100644 --- a/src/oai.rs +++ b/src/oai.rs @@ -1,16 +1,11 @@ use std::collections::BTreeMap; -use poem::{IntoResponse, Request, RequestBody, Response, Result}; +use poem::{Request, RequestBody, Result}; use poem_openapi::{ ApiExtractor, ApiExtractorType, ExtractParamOptions, __private::UrlQuery, - payload::Payload, - registry::{ - MetaHeader, MetaMediaType, MetaParamIn, MetaResponse, MetaResponses, MetaSchemaRef, - Registry, - }, + registry::{MetaParamIn, MetaSchemaRef, Registry}, types::Type, - ApiResponse, }; pub struct DynParams(pub BTreeMap); @@ -56,44 +51,3 @@ 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) {} -} diff --git a/src/page.rs b/src/page.rs index 8bf2243..008fe1d 100644 --- a/src/page.rs +++ b/src/page.rs @@ -6,7 +6,7 @@ use poem::{ IntoResponse, Request, Response, Result, }; -use crate::{storage::StorageError, util, Talon}; +use crate::{storage::StorageError, Talon}; #[derive(thiserror::Error, Debug)] pub enum PageError { @@ -27,17 +27,15 @@ pub async fn page(request: &Request, talon: Data<&Talon>) -> Result { let host = request .header(header::HOST) .ok_or(PageError::InvalidSubdomain)?; - let (subdomain, vid) = - util::parse_host(host, &talon.cfg.server.root_domain).ok_or(PageError::InvalidSubdomain)?; - - let vid = match vid { - Some(vid) => vid, - None => { - let ws = talon.db.get_website(subdomain)?; - ws.latest_version.ok_or(PageError::NoVersion)? - } + let subdomain = if host == talon.cfg.server.root_domain { + "-" + } else { + host.strip_suffix(&format!(".{}", talon.cfg.server.root_domain)) + .ok_or(PageError::InvalidSubdomain)? }; + let ws = talon.db.get_website(subdomain)?; + let vid = ws.latest_version.ok_or(PageError::NoVersion)?; let (file, ok) = match talon .storage diff --git a/src/storage.rs b/src/storage.rs index 3c972e1..cab461e 100644 --- a/src/storage.rs +++ b/src/storage.rs @@ -75,12 +75,10 @@ pub enum StorageError { InvalidFile(PathBuf), #[error("zip archive error: {0}")] Zip(#[from] zip::result::ZipError), - #[error("tar.gz archive error: {0}")] - Tgz(String), #[error("page `{0}` not found")] NotFound(String), - #[error("file `{0}` missing from storage")] - MissingFile(String), + #[error("file `{0}` of page `{1}` missing from storage")] + MissingFile(String, String), } impl ResponseError for StorageError { @@ -88,10 +86,7 @@ impl ResponseError for StorageError { match self { StorageError::Db(e) => e.status(), StorageError::NotFound(_) => StatusCode::NOT_FOUND, - StorageError::InvalidFile(_) | StorageError::Zip(_) | StorageError::Tgz(_) => { - StatusCode::BAD_REQUEST - } - StorageError::Io(_) | StorageError::MissingFile(_) => StatusCode::INTERNAL_SERVER_ERROR, + _ => StatusCode::INTERNAL_SERVER_ERROR, } } } @@ -247,9 +242,7 @@ impl Storage { let temp = TempDir::with_prefix(TMPDIR_PREFIX)?; let decoder = GzDecoder::new(reader); let mut archive = tar::Archive::new(decoder); - archive - .unpack(temp.path()) - .map_err(|e| StorageError::Tgz(e.to_string()))?; + archive.unpack(temp.path())?; let import_path = Self::fix_archive_path(temp.path())?; self.insert_dir(import_path, subdomain, version) } @@ -364,18 +357,8 @@ impl Storage { 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, - rd_path: Option, - headers: &HeaderMap, - ) -> Result { let algorithms = self.file_compressions( - hash, + &hash, mime.as_ref() .map(|m| Self::is_compressible(m.essence_str())) .unwrap_or_default(), @@ -385,12 +368,15 @@ impl Storage { match alg { Some(alg) => Ok(GotFile { hash: hash.encode_hex(), - file_path: self.file_path_compressed(hash, alg), + file_path: self.file_path_compressed(&hash, alg), encoding: alg.encoding(), mime, rd_path, }), - None => Err(StorageError::MissingFile(hash.encode_hex())), + None => Err(StorageError::MissingFile( + hash.encode_hex(), + new_path.into(), + )), } } @@ -444,7 +430,6 @@ impl Storage { } } - // HTML files are not precompressed and need to have UI code injected if gf .mime .as_ref() diff --git a/src/util.rs b/src/util.rs index 7e2a7c8..779544e 100644 --- a/src/util.rs +++ b/src/util.rs @@ -97,11 +97,9 @@ pub fn parse_accept_encoding( /// Subdomains may only contain letters a-z, numbers 0-9 and 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 `-` pub fn validate_subdomain(subdomain: &str) -> bool { - if subdomain.is_empty() || subdomain.len() > 200 || subdomain == "xn" || subdomain == "x" { + if subdomain.is_empty() || subdomain.len() > 200 { return false; } @@ -118,34 +116,6 @@ pub fn validate_subdomain(subdomain: &str) -> bool { .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)> { - 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>(path: P) -> Result<(), std::io::Error> { let path = path.as_ref(); if !path.is_dir() { diff --git a/tests/fixtures/mod.rs b/tests/fixtures/mod.rs index 60ceffe..38be02f 100644 --- a/tests/fixtures/mod.rs +++ b/tests/fixtures/mod.rs @@ -43,10 +43,6 @@ pub const HASH_SPA_INDEX: [u8; 32] = pub const HASH_SPA_FALLBACK: [u8; 32] = 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 { db: Db, _temp: TempDir, @@ -265,11 +261,6 @@ impl Deref for TalonTest { #[fixture] pub fn tln() -> TalonTest { 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(); insert_websites(&talon.db); diff --git a/tests/snapshots/tests__api__version_files.snap b/tests/snapshots/tests__api__version_files.snap deleted file mode 100644 index 51fe659..0000000 --- a/tests/snapshots/tests__api__version_files.snap +++ /dev/null @@ -1,41 +0,0 @@ ---- -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"), - ), -] diff --git a/tests/snapshots/tests__api__website_versions.snap b/tests/snapshots/tests__api__website_versions.snap deleted file mode 100644 index cfc49b7..0000000 --- a/tests/snapshots/tests__api__website_versions.snap +++ /dev/null @@ -1,22 +0,0 @@ ---- -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", - }, - ), -] diff --git a/tests/snapshots/tests__api__websites_get.snap b/tests/snapshots/tests__api__websites_get.snap deleted file mode 100644 index 72b052d..0000000 --- a/tests/snapshots/tests__api__websites_get.snap +++ /dev/null @@ -1,36 +0,0 @@ ---- -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), - ), -] diff --git a/tests/snapshots/tests__api__websites_get_all.snap b/tests/snapshots/tests__api__websites_get_all.snap deleted file mode 100644 index e79c55a..0000000 --- a/tests/snapshots/tests__api__websites_get_all.snap +++ /dev/null @@ -1,46 +0,0 @@ ---- -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), - ), -] diff --git a/tests/testfiles/config/config_test.toml b/tests/testfiles/config/config_test.toml deleted file mode 100644 index 92c8925..0000000 --- a/tests/testfiles/config/config_test.toml +++ /dev/null @@ -1,18 +0,0 @@ -# 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 = "*" diff --git a/tests/tests.rs b/tests/tests.rs index 9800e3d..6d4f204 100644 --- a/tests/tests.rs +++ b/tests/tests.rs @@ -587,7 +587,6 @@ mod page { #[case::rustypipe2("rustypipe", "/page2/index.html", &HASH_3_1_PAGE2, "text/html")] #[case::spa_index("spa", "/", &HASH_SPA_INDEX, "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( tln: TalonTest, #[case] subdomain: &str, @@ -685,495 +684,3 @@ 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::>()).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::>()).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::>()).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::>()).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::>()).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::, _>>() - .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::, _>>() - .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::() - )) - .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); - } -}