mod fixtures; use std::{fs::File, io::BufReader}; use path_macro::path; use rstest::rstest; use fixtures::*; use talon::db::{Db, DbError}; const ICON_SIZE: u32 = 48; mod database { use super::*; use talon::db::model::WebsiteUpdate; fn get_export(db: &Db) -> String { let mut buf: Vec = Vec::new(); db.export(&mut buf).unwrap(); String::from_utf8(buf).unwrap() } #[rstest] fn export(db: DbTest) { let data = get_export(&db); insta::assert_snapshot!("export", data); } #[rstest] fn export_import(db: DbTest) { let td = temp_testdir::TempDir::default(); let p_export = td.join("export.jsonl"); let p_db2 = td.join("db2"); db.export(File::create(&p_export).unwrap()).unwrap(); let db2 = Db::new(p_db2).unwrap(); db2.import(BufReader::new(File::open(&p_export).unwrap())) .unwrap(); let data = get_export(&db2); insta::assert_snapshot!("export", data); } #[rstest] fn get_website(db: DbTest) { let ws1 = db.get_website(SUBDOMAIN_1).unwrap(); let ws2 = db.get_website("spotify-gender-ex").unwrap(); let ws3 = db.get_website("rustypipe").unwrap(); insta::assert_ron_snapshot!(vec![ws1, ws2, ws3]); } #[rstest] fn delete_website(db: DbTest) { db.delete_website(SUBDOMAIN_1, true).unwrap(); assert!(matches!( db.get_website(SUBDOMAIN_1).unwrap_err(), DbError::NotExists(_, _) )); assert!(matches!( db.delete_website(SUBDOMAIN_1, true).unwrap_err(), DbError::NotExists(_, _) )); db.delete_website(SUBDOMAIN_1, false).unwrap(); let data = get_export(&db); insta::assert_snapshot!(data); } #[rstest] fn update_website(db: DbTest) { db.update_website( SUBDOMAIN_1, WebsiteUpdate { name: Some("ThetaDev2".to_owned()), color: Some(Some(1000)), visibility: Some(talon::model::Visibility::Hidden), source_url: Some(Some("https://example.com".to_owned())), source_icon: Some(Some(talon::model::SourceIcon::Link)), ..Default::default() }, ) .unwrap(); let website = db.get_website(SUBDOMAIN_1).unwrap(); insta::assert_ron_snapshot!(website, {".updated_at" => "[date]"}); } #[rstest] fn get_websites(db: DbTest) { let websites = db.get_websites().map(|w| w.unwrap()).collect::>(); insta::assert_ron_snapshot!(websites); } #[rstest] fn get_version(db: DbTest) { let version = db.get_version(SUBDOMAIN_1, 1).unwrap(); insta::assert_ron_snapshot!(version); } #[rstest] fn delete_version(db: DbTest) { db.delete_version(SUBDOMAIN_1, 2, true).unwrap(); assert!(matches!( db.get_version(SUBDOMAIN_1, 2).unwrap_err(), DbError::NotExists(_, _) )); assert!(matches!( db.delete_version(SUBDOMAIN_1, 2, true).unwrap_err(), DbError::NotExists(_, _) )); db.delete_version(SUBDOMAIN_1, 2, false).unwrap(); // Check if files were deleted assert!(db.get_version_files(SUBDOMAIN_1, 2).next().is_none()); // Check if latest version was updated let ws = db.get_website(SUBDOMAIN_1).unwrap(); assert_eq!(ws.latest_version, Some(1)); } #[rstest] fn get_website_versions(db: DbTest) { let versions = db .get_website_versions(SUBDOMAIN_1) .map(|v| v.unwrap()) .collect::>(); insta::assert_ron_snapshot!(versions); } #[rstest] fn get_website_version_ids(db: DbTest) { let ids = db .get_website_version_ids(SUBDOMAIN_1) .map(|v| v.unwrap()) .collect::>(); assert_eq!(ids, vec![1, 2]); } #[rstest] fn get_file(db: DbTest) { let hash = db.get_file(SUBDOMAIN_1, 1, "index.html").unwrap(); assert_eq!(hash, HASH_1_1_INDEX); } #[rstest] fn delete_file(db: DbTest) { db.delete_file(SUBDOMAIN_1, 1, "index.html", true).unwrap(); assert!(matches!( db.get_file(SUBDOMAIN_1, 1, "index.html").unwrap_err(), DbError::NotExists(_, _) )); assert!(matches!( db.delete_file(SUBDOMAIN_1, 1, "index.html", true) .unwrap_err(), DbError::NotExists(_, _) )); db.delete_file(SUBDOMAIN_1, 1, "index.html", false).unwrap(); } #[rstest] fn get_version_files(db: DbTest) { let files = db .get_version_files(SUBDOMAIN_1, 1) .map(|f| f.unwrap()) .collect::>(); assert_eq!( files, vec![ ("index.html".to_owned(), HASH_1_1_INDEX.to_vec()), ("style.css".to_owned(), HASH_1_1_STYLE.to_vec()) ] ); } #[rstest] fn get_file_hashes(db: DbTest) { let hashes = db.get_file_hashes().unwrap(); assert_eq!(hashes.len(), 11) } } mod storage { use std::{str::FromStr, time::SystemTime}; use hex::ToHex; use poem::{ error::StaticFileError, http::{header, HeaderMap, StatusCode}, web::headers::{self, HeaderMapExt}, }; use talon::storage::{GotFile, Storage}; use talon::{ config::{CompressionCfg, Config, ConfigInner}, storage::CompressionAlg, }; use time::OffsetDateTime; use super::*; #[rstest] fn insert_files(db_empty: DbTest) { let dir = path!("tests" / "testfiles" / "ThetaDev1"); let temp = temp_testdir::TempDir::default(); let store = Storage::new(temp.to_path_buf(), db_empty.clone(), Default::default()); store.insert_dir(dir, SUBDOMAIN_1, 1).unwrap(); let files = db_empty .get_version_files(SUBDOMAIN_1, 1) .map(|f| f.unwrap()) .collect::>(); insta::assert_ron_snapshot!("insert_files", files); for (_, hash) in files { let hash_str = hash.encode_hex::(); let path = temp.join(&hash_str[..2]).join(&hash_str); assert!(path.is_file()); } } #[rstest] fn insert_zip_archive(db_empty: DbTest) { let archive = path!("tests" / "testfiles" / "archive" / "ThetaDev1.zip"); let temp = temp_testdir::TempDir::default(); let store = Storage::new(temp.to_path_buf(), db_empty.clone(), Default::default()); store .insert_zip_archive(File::open(archive).unwrap(), SUBDOMAIN_1, 1) .unwrap(); let files = db_empty .get_version_files(SUBDOMAIN_1, 1) .map(|f| f.unwrap()) .collect::>(); insta::assert_ron_snapshot!("insert_files", files); for (_, hash) in files { let hash_str = hash.encode_hex::(); let path = temp.join(&hash_str[..2]).join(&hash_str); assert!(path.is_file()); } } #[rstest] fn insert_tgz_archive(db_empty: DbTest) { let archive = path!("tests" / "testfiles" / "archive" / "ThetaDev1.tar.gz"); let temp = temp_testdir::TempDir::default(); let store = Storage::new(temp.to_path_buf(), db_empty.clone(), Default::default()); store .insert_tgz_archive(File::open(archive).unwrap(), SUBDOMAIN_1, 1) .unwrap(); let files = db_empty .get_version_files(SUBDOMAIN_1, 1) .map(|f| f.unwrap()) .collect::>(); insta::assert_ron_snapshot!("insert_files", files); for (_, hash) in files { let hash_str = hash.encode_hex::(); let path = temp.join(&hash_str[..2]).join(&hash_str); assert!(path.is_file()); } } #[rstest] #[case::gzip(CompressionCfg {gzip_en: true, ..Default::default()}, "gz")] #[case::brotli(CompressionCfg {brotli_en: true, ..Default::default()}, "br")] fn insert_files_compressed( db_empty: DbTest, #[case] compression: CompressionCfg, #[case] ext: &str, ) { let dir = path!("tests" / "testfiles" / "ThetaDev1"); let temp = temp_testdir::TempDir::default(); let cfg = Config::new(ConfigInner { compression, ..Default::default() }); let store = Storage::new(temp.to_path_buf(), db_empty.clone(), cfg); store.insert_dir(dir, SUBDOMAIN_1, 1).unwrap(); for f in db_empty.get_version_files(SUBDOMAIN_1, 1) { let hash = f.unwrap().1; let hash_str = hash.encode_hex::(); let path = temp.join(&hash_str[..2]).join(&hash_str); let path_compressed = path.with_extension(ext); assert!(path.is_file()); // Images should not be compressed let expect = &hash_str != "901d291a47a8a9b55c06f84e5e5f82fd2dcee65cac1406d6e878b805d45c1e93" && &hash_str != "9f7e7971b4bfdb75429e534dea461ed90340886925078cda252cada9aa0e25f7" && &hash_str != "a44816e6c3b650bdf88e6532659ba07ef187c2113ae311da9709e056aec8eadb"; assert_eq!(path_compressed.is_file(), expect) } } #[rstest] #[case::index("br", SUBDOMAIN_1, 2, "", false, "text/html", None)] #[case::nocmp("", SUBDOMAIN_1, 2, "assets/style.css", true, "text/css", None)] #[case::gzip("gzip", SUBDOMAIN_1, 2, "assets/style.css", true, "text/css", None)] #[case::br("br", SUBDOMAIN_1, 2, "assets/style.css", true, "text/css", None)] #[case::image("br", SUBDOMAIN_1, 2, "assets/image.jpg", false, "image/jpeg", None)] #[case::subdir("br", SUBDOMAIN_3, 1, "page2", false, "text/html", Some("/page2/"))] fn get_file( tln: TalonTest, #[case] encoding: &str, #[case] subdomain: &str, #[case] version: u32, #[case] path: &str, #[case] compressible: bool, #[case] mime: &str, #[case] rd_path: Option<&str>, ) { let mut headers = HeaderMap::new(); headers.insert(header::ACCEPT_ENCODING, encoding.parse().unwrap()); let expect_ext = if compressible { match encoding { "gzip" => Some("gz"), "" => None, e => Some(e), } } else { None }; let index_file = tln .storage .get_file(subdomain, version, path, &headers) .unwrap(); dbg!(&index_file); assert!(index_file.file_path.is_file()); assert_eq!( index_file .file_path .extension() .map(|s| s.to_str().unwrap()), expect_ext ); assert_eq!( index_file.encoding, Some(encoding).filter(|s| compressible && !s.is_empty()) ); assert_eq!(index_file.mime.unwrap().essence_str(), mime); assert_eq!(index_file.rd_path.as_deref(), rd_path); } #[rstest] #[case::index(&HASH_1_2_INDEX, "text/html", "")] #[case::index_gz(&HASH_1_2_INDEX, "text/html", "gzip")] #[case::index_br(&HASH_1_2_INDEX, "text/html", "br")] #[case::style(&HASH_1_2_STYLE, "text/css", "")] fn file_to_response( tln: TalonTest, #[case] hash: &[u8], #[case] mime: &str, #[case] encoding: &str, ) { let gf = got_file(&tln, hash, mime); let file_date = std::fs::metadata(&gf.file_path) .unwrap() .modified() .unwrap(); let mut headers = HeaderMap::new(); if !encoding.is_empty() { headers.insert(header::ACCEPT_ENCODING, encoding.parse().unwrap()); } let resp = tokio_test::block_on(tln.storage.file_to_response(gf.clone(), &headers, true)).unwrap(); assert_eq!(resp.status(), StatusCode::OK); assert_eq!(resp.header(header::CONTENT_TYPE).unwrap(), mime); if mime == "text/html" { assert_eq!( resp.header(header::ETAG).unwrap(), format!("\"{}_{}\"", gf.hash, talon::build::PKG_VERSION) ); } else { assert_eq!( resp.header(header::ETAG).unwrap(), format!("\"{}\"", gf.hash) ); } let date = OffsetDateTime::from(SystemTime::from( resp.headers().typed_get::().unwrap(), )); assert!(date - file_date < time::Duration::SECOND); // HTML files should get dynamically compressed if mime == "text/html" && !encoding.is_empty() { assert_eq!(resp.header(header::CONTENT_ENCODING).unwrap(), encoding); } else { assert!(resp.header(header::CONTENT_ENCODING).is_none()) } } fn got_file(tln: &TalonTest, hash: &[u8], mime: &str) -> GotFile { let hash = hash.encode_hex::(); let file_path = path!(tln.temp / "storage" / &hash[..2] / &hash); GotFile { hash: hash.clone(), file_path, encoding: None, mime: Some(mime_guess::Mime::from_str(mime).unwrap()), rd_path: None, } } fn got_file_html(tln: &TalonTest) -> GotFile { got_file(tln, &HASH_1_2_INDEX, "text/html") } #[rstest] fn file_to_response_inject(tln: TalonTest) { let gf = got_file_html(&tln); let resp = tokio_test::block_on(tln.storage.file_to_response(gf, &HeaderMap::new(), true)) .unwrap(); let body = tokio_test::block_on(resp.into_body().into_string()).unwrap(); assert!(body.contains("\n")); } #[rstest] #[case::unmodified(true)] #[case::modified(true)] fn file_to_response_if_modified(tln: TalonTest, #[case] modified: bool) { let gf = got_file_html(&tln); let mut file_date = std::fs::metadata(&gf.file_path) .unwrap() .modified() .unwrap(); if modified { file_date -= std::time::Duration::from_secs(1); } let mut headers = HeaderMap::new(); headers.typed_insert(headers::IfModifiedSince::from(file_date)); let resp = tokio_test::block_on(tln.storage.file_to_response(gf, &headers, true)).unwrap(); assert_eq!( resp.status(), if modified { StatusCode::OK } else { StatusCode::NOT_MODIFIED } ); assert_eq!(resp.into_body().is_empty(), !modified); } #[rstest] #[case::unmodified(true)] #[case::modified(true)] fn file_to_response_if_unmodified(tln: TalonTest, #[case] modified: bool) { let gf = got_file_html(&tln); let mut file_date = std::fs::metadata(&gf.file_path) .unwrap() .modified() .unwrap(); if modified { file_date -= std::time::Duration::from_secs(1); } let mut headers = HeaderMap::new(); headers.typed_insert(headers::IfModifiedSince::from(file_date)); let res = tokio_test::block_on(tln.storage.file_to_response(gf, &headers, true)); if modified { let resp = res.unwrap(); assert_eq!(resp.status(), StatusCode::OK); assert!(!resp.into_body().is_empty()); } else { assert!(matches!( res.unwrap_err(), StaticFileError::PreconditionFailed )); } } #[rstest] #[case::matched(true)] #[case::no_match(false)] fn file_to_response_if_match(tln: TalonTest, #[case] matched: bool) { let gf = got_file_html(&tln); let etag = format!( "\"{}_{}\"", if matched { gf.hash.clone() } else { HASH_2_1_INDEX.encode_hex() }, talon::build::PKG_VERSION, ); let mut headers = HeaderMap::new(); headers.typed_insert(headers::IfMatch::from( headers::ETag::from_str(&etag).unwrap(), )); let res = tokio_test::block_on(tln.storage.file_to_response(gf, &headers, true)); if matched { let resp = res.unwrap(); assert_eq!(resp.status(), StatusCode::OK); assert!(!resp.into_body().is_empty()); } else { assert!(matches!( res.unwrap_err(), StaticFileError::PreconditionFailed )); } } #[rstest] #[case::matched(true)] #[case::no_match(false)] fn file_to_response_if_none_match(tln: TalonTest, #[case] matched: bool) { let gf = got_file_html(&tln); let etag = format!( "\"{}_{}\"", if matched { gf.hash.clone() } else { HASH_2_1_INDEX.encode_hex() }, talon::build::PKG_VERSION, ); let mut headers = HeaderMap::new(); headers.typed_insert(headers::IfNoneMatch::from( headers::ETag::from_str(&etag).unwrap(), )); let resp = tokio_test::block_on(tln.storage.file_to_response(gf, &headers, true)).unwrap(); assert_eq!( resp.status(), if matched { StatusCode::NOT_MODIFIED } else { StatusCode::OK } ); assert_eq!(resp.into_body().is_empty(), matched); } #[rstest] fn file_to_response_range(tln: TalonTest) { let gf = got_file(&tln, &HASH_2_1_STYLE, "text/css"); let mut headers = HeaderMap::new(); headers.typed_insert(headers::Range::bytes(0..100).unwrap()); let resp = tokio_test::block_on(tln.storage.file_to_response(gf, &headers, true)).unwrap(); assert_eq!(resp.status(), StatusCode::PARTIAL_CONTENT); let body = tokio_test::block_on(resp.into_body().into_bytes()).unwrap(); assert_eq!(body.len(), 100); } #[rstest] fn stored_files(tln: TalonTest) { let mut n = 0; for file in tln.storage.stored_files() { let file = file.unwrap(); if file.compression == CompressionAlg::None { let hash = talon::util::hash_file(&file.path).unwrap(); assert_eq!(file.hash, hash); } n += 1; } assert_eq!(n, 89); } #[rstest] fn purge(tln: TalonTest) { tln.db.delete_website("rustypipe", true).unwrap(); assert_eq!(tln.storage.purge().unwrap(), (5, 13798)); } } mod icons { use talon::icons::Icons; use super::*; use image::io::Reader as ImageReader; #[test] fn insert_icon() { let temp = temp_testdir::TempDir::default(); let icons = Icons::new(temp.to_path_buf()); let icon = File::open(path!("assets" / "icon.png")).unwrap(); icons.insert_icon(icon, "talon").unwrap(); let stored_path = path!(temp / "talon.png"); let got_path = icons.get_icon("talon").unwrap(); assert_eq!(stored_path, got_path); assert!(stored_path.is_file()); let stored_img = ImageReader::open(&stored_path).unwrap().decode().unwrap(); assert_eq!(stored_img.height(), ICON_SIZE); assert_eq!(stored_img.width(), ICON_SIZE); } #[test] fn delete_icon() { let temp = temp_testdir::TempDir::default(); let icons = Icons::new(temp.to_path_buf()); let icon = File::open(path!("assets" / "icon.png")).unwrap(); icons.insert_icon(icon, "talon").unwrap(); icons.delete_icon("talon").unwrap(); let stored_path = path!(temp / "talon.png"); assert!(!stored_path.exists()); assert!(matches!( icons.get_icon("talon"), Err(talon::icons::ImagesError::NotFound(_)) )); } } mod config { use talon::config::Config; use super::*; #[rstest] #[case::default("default", "config.toml")] #[case::sparse("sparse", "config_sparse.toml")] fn parse_config(#[case] name: &str, #[case] fname: &str) { let p = path!("tests" / "testfiles" / "config" / fname); let cfg = Config::from_file(p).unwrap(); insta::assert_ron_snapshot!(name, &cfg); } } mod page { use hex::ToHex; use poem::{ http::{header, StatusCode}, test::TestClient, }; use super::*; #[rstest] #[case::index("", "/", &HASH_1_2_INDEX, "text/html")] #[case::style("", "/assets/style.css", &HASH_1_2_STYLE, "text/css")] #[case::rustypipe("rustypipe", "/", &HASH_3_1_INDEX, "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_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, #[case] path: &str, #[case] hash: &[u8], #[case] mime: &str, ) { let host = if subdomain.is_empty() { "localhost:3000".to_owned() } else { format!("{subdomain}.localhost:3000") }; let resp = tokio_test::block_on( TestClient::new(tln.endpoint()) .get(format!("http://{host}{path}")) .header(header::HOST, host) .data(tln.clone()) .send(), ); resp.assert_status_is_ok(); resp.assert_content_type(mime); resp.assert_header( header::ETAG, if mime == "text/html" { format!( "\"{}_{}\"", hash.encode_hex::(), talon::build::PKG_VERSION ) } else { format!("\"{}\"", hash.encode_hex::()) }, ); } #[rstest] #[case::spa(Some("fallback.html".to_owned()), true, true, &HASH_1_2_INDEX)] #[case::index_fb(None, true, true, &HASH_1_1_INDEX)] #[case::err(Some("fallback.html".to_owned()), false, false, &HASH_1_2_INDEX)] fn page_fallback( tln: TalonTest, #[case] fallback: Option, #[case] spa: bool, #[case] ok: bool, #[case] hash: &[u8], ) { const SUBDOMAIN: &str = "fallback"; tln.db .insert_website( SUBDOMAIN, &talon::db::model::Website { latest_version: Some(1), ..Default::default() }, ) .unwrap(); assert_eq!( tln.db .insert_version( SUBDOMAIN, &talon::db::model::Version { fallback, spa, ..Default::default() }, ) .unwrap(), 1 ); tln.storage .insert_file( path!("tests" / "testfiles" / "ThetaDev0" / "index.html"), SUBDOMAIN, 1, "index.html", ) .unwrap(); tln.storage .insert_file( path!("tests" / "testfiles" / "ThetaDev1" / "index.html"), SUBDOMAIN, 1, "fallback.html", ) .unwrap(); let resp = tokio_test::block_on( TestClient::new(tln.endpoint()) .get("http://fallback.localhost:3000/test") .header(header::HOST, "fallback.localhost:3000") .data(tln.clone()) .send(), ); resp.assert_status(if ok { StatusCode::OK } else { StatusCode::NOT_FOUND }); resp.assert_content_type("text/html"); if ok { resp.assert_header( header::ETAG, format!( "\"{}_{}\"", hash.encode_hex::(), talon::build::PKG_VERSION ), ) } } } mod api { use std::io::{Cursor, Read}; use hex::ToHex; use hex_literal::hex; use image::io::Reader as ImageReader; 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("#1db954".to_string()), visibility: Visibility::Featured, source_url: Some("https://github.com/Theta-Dev/Spotify-Gender-Ex".to_owned()), source_icon: Some(SourceIcon::Github), has_icon: false, })); } #[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("#0003e8".to_owned()), 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]", ".updated_at" => "[date]"}, @r###" Website( name: "Test", created_at: "[date]", updated_at: "[date]", latest_version: None, color: Some(1000), visibility: searchable, source_url: Some("example.com"), source_icon: Some(git), vid_count: 0, has_icon: false, ) "###); } #[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("#0003e8".to_owned()), 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("#0003e8".to_owned())), 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, {".updated_at" => "[date]"}, @r###" Website( name: "Test", created_at: (2023, 49, 16, 30, 0, 0, 0, 0, 0), updated_at: "[date]", latest_version: Some(2), color: Some(1000), visibility: searchable, source_url: Some("example.com"), source_icon: Some(git), vid_count: 2, has_icon: false, ) "###); } #[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("#0003e8".to_owned())), 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_get_icon(tln: TalonTest) { let icon = File::open(path!("assets" / "icon.png")).unwrap(); tln.icons.insert_icon(icon, "-").unwrap(); let mut resp = tokio_test::block_on( TestClient::new(tln.endpoint()) .get("http://talon.localhost:3000/icons/-") .header(header::HOST, "talon.localhost:3000") .data(tln.clone()) .send(), ); resp.assert_status_is_ok(); resp.assert_header(header::CONTENT_TYPE, "image/png"); let icon_bts = tokio_test::block_on(resp.0.take_body().into_bytes()).unwrap(); let got_icon = ImageReader::new(BufReader::new(Cursor::new(icon_bts))) .with_guessed_format() .unwrap() .decode() .unwrap(); assert_eq!(got_icon.height(), ICON_SIZE); assert_eq!(got_icon.width(), ICON_SIZE); } #[rstest] fn website_get_icon_404(tln: TalonTest) { let resp = tokio_test::block_on( TestClient::new(tln.endpoint()) .get("http://talon.localhost:3000/icons/-") .header(header::HOST, "talon.localhost:3000") .data(tln.clone()) .send(), ); resp.assert_status(StatusCode::NOT_FOUND); } #[rstest] fn website_upload_icon(tln: TalonTest) { let mut icon = File::open(path!("assets" / "icon.png")).unwrap(); let mut icon_bts = Vec::new(); icon.read_to_end(&mut icon_bts).unwrap(); let resp = tokio_test::block_on( TestClient::new(tln.endpoint()) .put("http://talon.localhost:3000/api/website/-/icon") .header(header::HOST, "talon.localhost:3000") .header(header::CONTENT_TYPE, "application/octet-stream") .header("x-api-key", API_KEY_ROOT) .data(tln.clone()) .body(icon_bts) .send(), ); resp.assert_status_is_ok(); let resp = tokio_test::block_on( TestClient::new(tln.endpoint()) .get("http://talon.localhost:3000/api/website/-") .header(header::HOST, "talon.localhost:3000") .data(tln.clone()) .send(), ); resp.assert_status_is_ok(); tokio_test::block_on(resp.assert_json(Website { subdomain: "-".to_string(), name: "ThetaDev".to_owned(), created_at: datetime!(2023-02-18 16:30 +0), latest_version: Some(2), color: Some("#1f91ee".to_owned()), visibility: talon::model::Visibility::Featured, source_icon: None, source_url: None, has_icon: true, })); } #[rstest] fn website_delete_icon(tln: TalonTest) { let icon = File::open(path!("assets" / "icon.png")).unwrap(); tln.icons.insert_icon(icon, "-").unwrap(); let resp = tokio_test::block_on( TestClient::new(tln.endpoint()) .delete("http://talon.localhost:3000/api/website/-/icon") .header(header::HOST, "talon.localhost:3000") .header("x-api-key", API_KEY_ROOT) .data(tln.clone()) .send(), ); resp.assert_status_is_ok(); } #[rstest] fn website_delete_icon_404(tln: TalonTest) { let resp = tokio_test::block_on( TestClient::new(tln.endpoint()) .delete("http://talon.localhost:3000/api/website/-/icon") .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 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); // Check that no zombie version remains assert_eq!(tln.db.get_website_versions("rustypipe").count(), 1); assert_eq!(tln.db.get_website("rustypipe").unwrap().vid_count, 1); } #[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); // Check that no zombie version remains assert_eq!(tln.db.get_website_versions("rustypipe").count(), 1); assert_eq!(tln.db.get_website("rustypipe").unwrap().vid_count, 1); } #[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)] #[case::icon_upload("website/test/icon", Method::PUT)] #[case::icon_delete("website/test/icon", Method::DELETE)] fn unauthorized(tln: TalonTest, #[case] endpoint: &str, #[case] method: Method) { let resp = tokio_test::block_on( TestClient::new(tln.endpoint()) .request( method.clone(), format!("http://talon.localhost:3000/api/{endpoint}"), ) .header(header::HOST, "talon.localhost:3000") .data(tln.clone()) .send(), ); resp.assert_status(StatusCode::UNAUTHORIZED); let resp = match method { Method::POST => tokio_test::block_on( TestClient::new(tln.endpoint()) .request( method.clone(), format!("http://talon.localhost:3000/api/{endpoint}"), ) .header(header::HOST, "talon.localhost:3000") .header(header::CONTENT_TYPE, "application/octet-stream") .header("x-api-key", API_KEY_RO) .data(tln.clone()) .send(), ), Method::GET | Method::PUT | Method::PATCH => return, _ => tokio_test::block_on( TestClient::new(tln.endpoint()) .request( method.clone(), format!("http://talon.localhost:3000/api/{endpoint}"), ) .header(header::HOST, "talon.localhost:3000") .header("x-api-key", API_KEY_RO) .data(tln.clone()) .body_json(&serde_json::Value::Null) .send(), ), }; resp.assert_status(StatusCode::FORBIDDEN); } }