From bf025b4977543a371df9dbdddfe9cc2f02f2a8d0 Mon Sep 17 00:00:00 2001 From: Joel Klinghed Date: Sun, 9 Feb 2025 23:56:38 +0100 Subject: First integration test Sets up a whole slew of docker instances, all started from clean slate for test. --- server/Cargo.lock | 276 ++++++++++++++++++++++++++++++- server/Cargo.toml | 10 +- server/api/Cargo.toml | 9 ++ server/api/src/api_model.rs | 263 ++++++++++++++++++++++++++++++ server/api/src/lib.rs | 1 + server/src/api_model.rs | 240 --------------------------- server/src/main.rs | 25 +-- server/tests/common/mod.rs | 340 +++++++++++++++++++++++++++++++++++++++ server/tests/integration_test.rs | 128 +++++++++++++++ 9 files changed, 1023 insertions(+), 269 deletions(-) create mode 100644 server/api/Cargo.toml create mode 100644 server/api/src/api_model.rs create mode 100644 server/api/src/lib.rs delete mode 100644 server/src/api_model.rs create mode 100644 server/tests/common/mod.rs create mode 100644 server/tests/integration_test.rs (limited to 'server') diff --git a/server/Cargo.lock b/server/Cargo.lock index 5664b03..e44bc77 100644 --- a/server/Cargo.lock +++ b/server/Cargo.lock @@ -318,6 +318,24 @@ dependencies = [ "version_check", ] +[[package]] +name = "cookie_store" +version = "0.21.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2eac901828f88a5241ee0600950ab981148a18f2f756900ffba1b125ca6a3ef9" +dependencies = [ + "cookie", + "document-features", + "idna", + "log", + "publicsuffix", + "serde", + "serde_derive", + "serde_json", + "time", + "url", +] + [[package]] name = "core-foundation" version = "0.9.4" @@ -489,6 +507,15 @@ dependencies = [ "syn 2.0.98", ] +[[package]] +name = "document-features" +version = "0.2.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb6969eaabd2421f8a2775cfd2471a2b634372b4a25d41e3bd647b79912850a0" +dependencies = [ + "litrs", +] + [[package]] name = "dotenvy" version = "0.15.7" @@ -551,15 +578,18 @@ name = "eyeballs" version = "0.1.0" dependencies = [ "anyhow", + "eyeballs-api", "eyeballs-common", "futures", "ldap3", + "reqwest", "rmp-serde", "rocket", "rocket_db_pools", "serde", "sqlx", "stdext", + "test-context", "testdir", "time", "tokio", @@ -567,6 +597,14 @@ dependencies = [ "utoipa-swagger-ui", ] +[[package]] +name = "eyeballs-api" +version = "0.1.0" +dependencies = [ + "serde", + "utoipa", +] + [[package]] name = "eyeballs-common" version = "0.1.0" @@ -958,6 +996,29 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.2.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "793429d76616a256bcb62c2a2ec2bed781c8307e797e2598c50010f2bee2544f" +dependencies = [ + "bytes", + "futures-util", + "http 1.2.0", + "http-body 1.0.1", + "pin-project-lite", +] + [[package]] name = "httparse" version = "1.10.0" @@ -982,7 +1043,7 @@ dependencies = [ "futures-util", "h2", "http 0.2.12", - "http-body", + "http-body 0.4.6", "httparse", "httpdate", "itoa", @@ -994,6 +1055,44 @@ dependencies = [ "want", ] +[[package]] +name = "hyper" +version = "1.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc2b571658e38e0c01b1fdca3bbbe93c00d3d71693ff2770043f8c29bc7d6f80" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http 1.2.0", + "http-body 1.0.1", + "httparse", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", + "want", +] + +[[package]] +name = "hyper-util" +version = "0.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df2dcfbe0677734ab2f3ffa7fa7bfd4706bfdc1ef393f2ee30184aed67e631b4" +dependencies = [ + "bytes", + "futures-channel", + "futures-util", + "http 1.2.0", + "http-body 1.0.1", + "hyper 1.6.0", + "pin-project-lite", + "socket2", + "tokio", + "tower-service", + "tracing", +] + [[package]] name = "icu_collections" version = "1.5.0" @@ -1159,6 +1258,12 @@ dependencies = [ "generic-array", ] +[[package]] +name = "ipnet" +version = "2.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" + [[package]] name = "is-terminal" version = "0.4.15" @@ -1264,6 +1369,12 @@ version = "0.7.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ee93343901ab17bd981295f2cf0026d4ad018c7c31ba84549a4ddbb47a45104" +[[package]] +name = "litrs" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ce301924b7887e9d637144fdade93f9dfff9b60981d4ac161db09720d39aa5" + [[package]] name = "lock_api" version = "0.4.12" @@ -1728,6 +1839,22 @@ dependencies = [ "yansi", ] +[[package]] +name = "psl-types" +version = "2.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "33cb294fe86a74cbcf50d4445b37da762029549ebeea341421c7c70370f86cac" + +[[package]] +name = "publicsuffix" +version = "2.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f42ea446cab60335f76979ec15e12619a2165b5ae2c12166bef27d283a9fadf" +dependencies = [ + "idna", + "psl-types", +] + [[package]] name = "quote" version = "1.0.38" @@ -1840,6 +1967,44 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "reqwest" +version = "0.12.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43e734407157c3c2034e0258f5e4473ddb361b1e85f95a66690d67264d7cd1da" +dependencies = [ + "base64 0.22.1", + "bytes", + "cookie", + "cookie_store", + "futures-core", + "futures-util", + "http 1.2.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.6.0", + "hyper-util", + "ipnet", + "js-sys", + "log", + "mime", + "once_cell", + "percent-encoding", + "pin-project-lite", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-registry", +] + [[package]] name = "ring" version = "0.17.8" @@ -1964,7 +2129,7 @@ dependencies = [ "either", "futures", "http 0.2.12", - "hyper", + "hyper 0.14.32", "indexmap", "log", "memchr", @@ -2209,6 +2374,18 @@ dependencies = [ "serde", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "sha1" version = "0.10.6" @@ -2588,6 +2765,15 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + [[package]] name = "synstructure" version = "0.13.1" @@ -2627,6 +2813,27 @@ dependencies = [ "windows-sys 0.59.0", ] +[[package]] +name = "test-context" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb69cce03e432993e2dc1f93f7899b952300fcb6dc44191a1b830b60b8c3c8aa" +dependencies = [ + "futures", + "test-context-macros", +] + +[[package]] +name = "test-context-macros" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97e0639209021e54dbe19cafabfc0b5574b078c37358945e6d473eabe39bb974" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.98", +] + [[package]] name = "testdir" version = "0.9.3" @@ -2845,6 +3052,27 @@ dependencies = [ "winnow", ] +[[package]] +name = "tower" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d039ad9159c98b70ecfd540b2573b97f7f52c3e8d9f8ad57a24b916a536975f9" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper", + "tokio", + "tower-layer", + "tower-service", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + [[package]] name = "tower-service" version = "0.3.3" @@ -3154,6 +3382,7 @@ checksum = "1edc8929d7499fc4e8f0be2262a241556cfc54a0bea223790e71446f2aab1ef5" dependencies = [ "cfg-if", "once_cell", + "rustversion", "wasm-bindgen-macro", ] @@ -3171,6 +3400,19 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.50" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "555d470ec0bc3bb57890405e5d4322cc9ea83cebb085523ced7be4144dac1e61" +dependencies = [ + "cfg-if", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.100" @@ -3270,6 +3512,36 @@ dependencies = [ "windows-targets 0.48.5", ] +[[package]] +name = "windows-registry" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e400001bb720a623c1c69032f8e3e4cf09984deec740f007dd2b03ec864804b0" +dependencies = [ + "windows-result", + "windows-strings", + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-result" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d1043d8214f791817bab27572aaa8af63732e11bf84aa21a45a78d6c317ae0e" +dependencies = [ + "windows-targets 0.52.6", +] + +[[package]] +name = "windows-strings" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cd9b125c486025df0eabcb585e62173c6c9eddcec5d117d3b6e8c30e2ee4d10" +dependencies = [ + "windows-result", + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.48.0" diff --git a/server/Cargo.toml b/server/Cargo.toml index 18a6458..b9d0488 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -5,8 +5,8 @@ edition = "2021" default-run = "eyeballs" [workspace] -members = ["hook"] -default-members = [".", "common", "hook"] +members = ["api", "hook"] +default-members = [".", "api", "common", "hook"] resolver = "2" [workspace.dependencies] @@ -15,9 +15,11 @@ rmp-serde = "1.3" serde = { version = "1.0", features = ["derive"] } testdir = "0.9.3" tokio = { version = "1" } +utoipa = { version = "5" } [dependencies] anyhow = "1.0" +eyeballs-api = { path = "api" } eyeballs-common = { path = "common" } futures.workspace = true ldap3 = { version = "0.11.5", default-features = false, features = [ "native-tls", "tls", "tls-native", "tokio-native-tls" ] } @@ -28,9 +30,11 @@ serde.workspace = true sqlx = { version = "0.7.0", default-features = false, features = ["macros", "migrate"] } time = "0.3.34" tokio = { workspace = true, features = ["process"] } -utoipa = { version = "5", features = ["rocket_extras"] } +utoipa = { workspace = true, features = ["rocket_extras"] } utoipa-swagger-ui = { version = "9", features = ["rocket", "vendored"], default-features = false } [dev-dependencies] +reqwest = { version = "0.12.12", features = ["cookies", "json"], default-features = false } stdext = "0.3.3" +test-context = "0.4.1" testdir.workspace = true diff --git a/server/api/Cargo.toml b/server/api/Cargo.toml new file mode 100644 index 0000000..0da892e --- /dev/null +++ b/server/api/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "eyeballs-api" +version = "0.1.0" +edition = "2021" + +[dependencies] +serde.workspace = true +utoipa.workspace = true + diff --git a/server/api/src/api_model.rs b/server/api/src/api_model.rs new file mode 100644 index 0000000..3760f9e --- /dev/null +++ b/server/api/src/api_model.rs @@ -0,0 +1,263 @@ +use serde::{Deserialize, Serialize}; +use utoipa::ToSchema; + +#[derive(Copy, Clone, Deserialize, Serialize, ToSchema)] +pub enum ReviewState { + Draft, + Open, + Dropped, + Closed, +} + +impl TryFrom for ReviewState { + type Error = &'static str; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(ReviewState::Draft), + 1 => Ok(ReviewState::Open), + 2 => Ok(ReviewState::Dropped), + 3 => Ok(ReviewState::Closed), + _ => Err("Invalid review state"), + } + } +} + +#[derive(Copy, Clone, Deserialize, Serialize, ToSchema)] +pub enum Rewrite { + Disabled, + History, + Rebase, +} + +impl TryFrom for Rewrite { + type Error = &'static str; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(Rewrite::Disabled), + 1 => Ok(Rewrite::History), + 2 => Ok(Rewrite::Rebase), + _ => Err("Invalid review state"), + } + } +} + +#[derive(Copy, Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] +pub enum UserReviewRole { + Reviewer, + Watcher, + None, +} + +impl TryFrom for UserReviewRole { + type Error = &'static str; + + fn try_from(value: u8) -> Result { + match value { + 0 => Ok(UserReviewRole::None), + 1 => Ok(UserReviewRole::Reviewer), + 2 => Ok(UserReviewRole::Watcher), + _ => Err("Invalid role"), + } + } +} + +impl From for u8 { + fn from(value: UserReviewRole) -> u8 { + match value { + UserReviewRole::None => 0, + UserReviewRole::Reviewer => 1, + UserReviewRole::Watcher => 2, + } + } +} + +#[derive(Debug, Deserialize, Serialize, PartialEq, ToSchema)] +pub struct User { + #[schema(example = "jsmith")] + pub id: String, + #[schema(example = "John Smith")] + pub name: String, + #[schema(example = true)] + pub active: bool, +} + +#[derive(Deserialize, Serialize, ToSchema)] +pub struct Users { + #[schema(example = 0u32)] + pub offset: u32, + #[schema(example = 10u32)] + pub limit: u32, + #[schema(example = 42u32)] + pub total_count: u32, + #[schema(example = true)] + pub more: bool, + pub users: Vec, +} + +#[derive(Serialize, ToSchema)] +pub struct ReviewUserEntry { + pub user: User, + #[schema(example = UserReviewRole::Reviewer)] + pub role: UserReviewRole, +} + +#[derive(Serialize, ToSchema)] +pub struct Review { + #[schema(example = 1000u64)] + pub id: u64, + #[schema(example = "FAKE-512: Add more features")] + pub title: String, + #[schema(example = "We're adding more features because features are what we want.")] + pub description: String, + pub owner: User, + pub users: Vec, + #[schema(example = ReviewState::Open)] + pub state: ReviewState, + #[schema(example = 37.5)] + pub progress: f32, + #[schema(example = "r/user/TASK-123456")] + pub branch: String, + #[schema(example = false)] + pub archived: bool, +} + +#[derive(Deserialize, Serialize, ToSchema)] +pub struct ReviewEntry { + #[schema(example = 1000u64)] + pub id: u64, + #[schema(example = "FAKE-512: Add more features")] + pub title: String, + pub owner: User, + #[schema(example = ReviewState::Open)] + pub state: ReviewState, + #[schema(example = 37.5)] + pub progress: f32, + #[schema(example = "r/user/TASK-123456")] + pub branch: String, +} + +#[derive(Deserialize, Serialize, ToSchema)] +pub struct Reviews { + #[schema(example = 0u32)] + pub offset: u32, + #[schema(example = 10u32)] + pub limit: u32, + #[schema(example = 42u32)] + pub total_count: u32, + #[schema(example = true)] + pub more: bool, + pub reviews: Vec, +} + +#[derive(Debug, Deserialize, PartialEq, Serialize, ToSchema)] +pub struct ProjectUserEntry { + pub user: User, + #[schema(example = UserReviewRole::Reviewer)] + pub default_role: UserReviewRole, + #[schema(example = false)] + pub maintainer: bool, +} + +#[derive(Deserialize, Serialize, ToSchema)] +pub struct ProjectUserEntryData { + #[schema(example = UserReviewRole::Reviewer)] + pub default_role: Option, + #[schema(example = false)] + pub maintainer: Option, +} + +#[derive(Debug, Deserialize, PartialEq, Serialize, ToSchema)] +pub struct Project { + #[schema(example = "fake")] + pub id: String, + #[schema(example = "FAKE: Features All Kids Erase")] + pub title: String, + #[schema(example = "Example project")] + pub description: String, + #[schema(example = "ssh://git.example.org/srv/git/")] + pub remote: String, + #[schema(example = "main")] + pub main_branch: String, + pub users: Vec, +} + +#[derive(Deserialize, Serialize, ToSchema)] +pub struct ProjectData<'r> { + #[schema(example = "FAKE: Features All Kids Erase")] + pub title: Option<&'r str>, + #[schema(example = "Example project")] + pub description: Option<&'r str>, + #[schema(example = "ssh://git.example.org/srv/git/")] + pub remote: Option<&'r str>, + #[schema(example = "main")] + pub main_branch: Option<&'r str>, +} + +#[derive(Deserialize, Serialize, ToSchema)] +pub struct ProjectEntry { + #[schema(example = "fake")] + pub id: String, + #[schema(example = "FAKE: Features All Kids Erase")] + pub title: String, +} + +#[derive(Deserialize, Serialize, ToSchema)] +pub struct Projects { + #[schema(example = 0u32)] + pub offset: u32, + #[schema(example = 10u32)] + pub limit: u32, + #[schema(example = 1u32)] + pub total_count: u32, + #[schema(example = false)] + pub more: bool, + pub projects: Vec, +} + +#[derive(Deserialize, Serialize, ToSchema)] +pub struct StatusResponse { + pub ok: bool, + #[serde( + skip_serializing_if = "Option::is_none", + // &'static str is problematic for serde, only used in tests anyway. + skip_deserializing, + )] + pub error: Option<&'static str>, +} + +#[derive(Debug, Deserialize, PartialEq, Serialize, ToSchema)] +pub struct UserKey { + #[schema(example = 1u64)] + pub id: u64, + #[schema(example = "ssh-rsa")] + pub kind: String, + #[schema(example = "AAAAfoobar==")] + pub data: String, + #[schema(example = "user@host 1970-01-01")] + pub comment: String, +} + +#[derive(Deserialize, Serialize, ToSchema)] +pub struct UserKeyData<'r> { + #[schema(example = "ssh-rsa")] + pub kind: &'r str, + #[schema(example = "AAAAfoobar==")] + pub data: &'r str, + #[schema(example = "user@host 1970-01-01")] + pub comment: Option<&'r str>, +} + +#[derive(Deserialize, Serialize, ToSchema)] +pub struct UserKeys { + #[schema(example = 0u32)] + pub offset: u32, + #[schema(example = 10u32)] + pub limit: u32, + #[schema(example = 2u32)] + pub total_count: u32, + #[schema(example = false)] + pub more: bool, + pub keys: Vec, +} diff --git a/server/api/src/lib.rs b/server/api/src/lib.rs new file mode 100644 index 0000000..75860d1 --- /dev/null +++ b/server/api/src/lib.rs @@ -0,0 +1 @@ +pub mod api_model; diff --git a/server/src/api_model.rs b/server/src/api_model.rs deleted file mode 100644 index dbb42d8..0000000 --- a/server/src/api_model.rs +++ /dev/null @@ -1,240 +0,0 @@ -use rocket::serde::{Deserialize, Serialize}; -use utoipa::ToSchema; - -#[derive(Copy, Clone, Deserialize, Serialize, ToSchema)] -pub enum ReviewState { - Draft, - Open, - Dropped, - Closed, -} - -impl TryFrom for ReviewState { - type Error = &'static str; - - fn try_from(value: u8) -> Result { - match value { - 0 => Ok(ReviewState::Draft), - 1 => Ok(ReviewState::Open), - 2 => Ok(ReviewState::Dropped), - 3 => Ok(ReviewState::Closed), - _ => Err("Invalid review state"), - } - } -} - -#[derive(Copy, Clone, Deserialize, Serialize, ToSchema)] -pub enum Rewrite { - Disabled, - History, - Rebase, -} - -impl TryFrom for Rewrite { - type Error = &'static str; - - fn try_from(value: u8) -> Result { - match value { - 0 => Ok(Rewrite::Disabled), - 1 => Ok(Rewrite::History), - 2 => Ok(Rewrite::Rebase), - _ => Err("Invalid review state"), - } - } -} - -#[derive(Copy, Clone, Debug, Deserialize, PartialEq, Serialize, ToSchema)] -pub enum UserReviewRole { - Reviewer, - Watcher, - None, -} - -#[derive(Debug, Deserialize, Serialize, PartialEq, ToSchema)] -pub struct User { - #[schema(example = "jsmith")] - pub id: String, - #[schema(example = "John Smith")] - pub name: String, - #[schema(example = true)] - pub active: bool, -} - -#[derive(Deserialize, Serialize, ToSchema)] -pub struct Users { - #[schema(example = 0u32)] - pub offset: u32, - #[schema(example = 10u32)] - pub limit: u32, - #[schema(example = 42u32)] - pub total_count: u32, - #[schema(example = true)] - pub more: bool, - pub users: Vec, -} - -#[derive(Serialize, ToSchema)] -pub struct ReviewUserEntry { - pub user: User, - #[schema(example = UserReviewRole::Reviewer)] - pub role: UserReviewRole, -} - -#[derive(Serialize, ToSchema)] -pub struct Review { - #[schema(example = 1000u64)] - pub id: u64, - #[schema(example = "FAKE-512: Add more features")] - pub title: String, - #[schema(example = "We're adding more features because features are what we want.")] - pub description: String, - pub owner: User, - pub users: Vec, - #[schema(example = ReviewState::Open)] - pub state: ReviewState, - #[schema(example = 37.5)] - pub progress: f32, - #[schema(example = "r/user/TASK-123456")] - pub branch: String, - #[schema(example = false)] - pub archived: bool, -} - -#[derive(Deserialize, Serialize, ToSchema)] -pub struct ReviewEntry { - #[schema(example = 1000u64)] - pub id: u64, - #[schema(example = "FAKE-512: Add more features")] - pub title: String, - pub owner: User, - #[schema(example = ReviewState::Open)] - pub state: ReviewState, - #[schema(example = 37.5)] - pub progress: f32, - #[schema(example = "r/user/TASK-123456")] - pub branch: String, -} - -#[derive(Deserialize, Serialize, ToSchema)] -pub struct Reviews { - #[schema(example = 0u32)] - pub offset: u32, - #[schema(example = 10u32)] - pub limit: u32, - #[schema(example = 42u32)] - pub total_count: u32, - #[schema(example = true)] - pub more: bool, - pub reviews: Vec, -} - -#[derive(Debug, Deserialize, PartialEq, Serialize, ToSchema)] -pub struct ProjectUserEntry { - pub user: User, - #[schema(example = UserReviewRole::Reviewer)] - pub default_role: UserReviewRole, - #[schema(example = false)] - pub maintainer: bool, -} - -#[derive(Deserialize, Serialize, ToSchema)] -pub struct ProjectUserEntryData { - #[schema(example = UserReviewRole::Reviewer)] - pub default_role: Option, - #[schema(example = false)] - pub maintainer: Option, -} - -#[derive(Debug, Deserialize, PartialEq, Serialize, ToSchema)] -pub struct Project { - #[schema(example = "fake")] - pub id: String, - #[schema(example = "FAKE: Features All Kids Erase")] - pub title: String, - #[schema(example = "Example project")] - pub description: String, - #[schema(example = "ssh://git.example.org/srv/git/")] - pub remote: String, - #[schema(example = "main")] - pub main_branch: String, - pub users: Vec, -} - -#[derive(Deserialize, Serialize, ToSchema)] -pub struct ProjectData<'r> { - #[schema(example = "FAKE: Features All Kids Erase")] - pub title: Option<&'r str>, - #[schema(example = "Example project")] - pub description: Option<&'r str>, - #[schema(example = "ssh://git.example.org/srv/git/")] - pub remote: Option<&'r str>, - #[schema(example = "main")] - pub main_branch: Option<&'r str>, -} - -#[derive(Deserialize, Serialize, ToSchema)] -pub struct ProjectEntry { - #[schema(example = "fake")] - pub id: String, - #[schema(example = "FAKE: Features All Kids Erase")] - pub title: String, -} - -#[derive(Deserialize, Serialize, ToSchema)] -pub struct Projects { - #[schema(example = 0u32)] - pub offset: u32, - #[schema(example = 10u32)] - pub limit: u32, - #[schema(example = 1u32)] - pub total_count: u32, - #[schema(example = false)] - pub more: bool, - pub projects: Vec, -} - -#[derive(Deserialize, Serialize, ToSchema)] -pub struct StatusResponse { - pub ok: bool, - #[serde( - skip_serializing_if = "Option::is_none", - // &'static str is problematic for serde, only used in tests anyway. - skip_deserializing, - )] - pub error: Option<&'static str>, -} - -#[derive(Debug, Deserialize, PartialEq, Serialize, ToSchema)] -pub struct UserKey { - #[schema(example = 1u64)] - pub id: u64, - #[schema(example = "ssh-rsa")] - pub kind: String, - #[schema(example = "AAAAfoobar==")] - pub data: String, - #[schema(example = "user@host 1970-01-01")] - pub comment: String, -} - -#[derive(Deserialize, Serialize, ToSchema)] -pub struct UserKeyData<'r> { - #[schema(example = "ssh-rsa")] - pub kind: &'r str, - #[schema(example = "AAAAfoobar==")] - pub data: &'r str, - #[schema(example = "user@host 1970-01-01")] - pub comment: Option<&'r str>, -} - -#[derive(Deserialize, Serialize, ToSchema)] -pub struct UserKeys { - #[schema(example = 0u32)] - pub offset: u32, - #[schema(example = 10u32)] - pub limit: u32, - #[schema(example = 2u32)] - pub total_count: u32, - #[schema(example = false)] - pub more: bool, - pub keys: Vec, -} diff --git a/server/src/main.rs b/server/src/main.rs index f07c372..9bdfeaf 100644 --- a/server/src/main.rs +++ b/server/src/main.rs @@ -14,6 +14,7 @@ use std::path::PathBuf; use utoipa::OpenApi; use utoipa_swagger_ui::SwaggerUi; +use eyeballs_api::api_model; use eyeballs_common::fs_utils; use eyeballs_common::git; use eyeballs_common::git_socket; @@ -21,7 +22,6 @@ use eyeballs_common::git_socket; #[cfg(test)] mod tests; -mod api_model; mod auth; mod authorized_keys; mod db_utils; @@ -58,29 +58,6 @@ struct Db(sqlx::MySqlPool); )] pub struct MainApi; -impl TryFrom for api_model::UserReviewRole { - type Error = &'static str; - - fn try_from(value: u8) -> Result { - match value { - 0 => Ok(api_model::UserReviewRole::None), - 1 => Ok(api_model::UserReviewRole::Reviewer), - 2 => Ok(api_model::UserReviewRole::Watcher), - _ => Err("Invalid role"), - } - } -} - -impl From for u8 { - fn from(value: api_model::UserReviewRole) -> u8 { - match value { - api_model::UserReviewRole::None => 0, - api_model::UserReviewRole::Reviewer => 1, - api_model::UserReviewRole::Watcher => 2, - } - } -} - #[utoipa::path( responses( (status = 200, description = "Get all projects", body = api_model::Projects), diff --git a/server/tests/common/mod.rs b/server/tests/common/mod.rs new file mode 100644 index 0000000..0eef90b --- /dev/null +++ b/server/tests/common/mod.rs @@ -0,0 +1,340 @@ +use reqwest::Client; +use std::collections::HashMap; +use std::env; +use std::fs::Permissions; +use std::os::unix::fs::PermissionsExt; +use std::path::{Path, PathBuf}; +use std::process::Stdio; +use test_context::AsyncTestContext; +use testdir::testdir; +use tokio::fs; +use tokio::process::Command; + +use eyeballs_api::api_model; + +pub struct DockerComposeContext { + docker_dir: PathBuf, + test_dir: PathBuf, + url: String, + remote_git: String, +} + +async fn run(cmd: &mut Command, name: &str) -> Result<(), anyhow::Error> { + cmd.stdin(Stdio::null()) + .stdout(Stdio::null()) + .stderr(Stdio::piped()); + let child = cmd.spawn()?; + let output = child.wait_with_output().await?; + + if output.status.success() { + Ok(()) + } else { + Err(anyhow::Error::msg(format!( + "{name} failed with exitcode: {}\n{:?}\n{}", + output.status, + cmd.as_std().get_args(), + std::str::from_utf8(output.stderr.as_slice()).unwrap_or(""), + ))) + } +} + +async fn setup_ssh_file( + base: impl AsRef, + host: &str, + port: &str, + identity_file: impl AsRef, +) -> Result<(), anyhow::Error> { + let full_identity_file = identity_file.as_ref().canonicalize()?; + + fs::write( + base.as_ref().join("ssh_config"), + format!( + "Host {host} + StrictHostKeyChecking no + UserKnownHostsFile /dev/null + UpdateHostKeys no + Port {port} + User git + IdentityFile {} +", + full_identity_file.to_str().unwrap(), + ), + ) + .await?; + + Ok(()) +} + +async fn git_clone(base: impl AsRef) -> Result<(), anyhow::Error> { + let mut cmd = Command::new("git"); + cmd.arg("clone"); + cmd.arg("ssh://localhost/srv/git/fake.git"); + + cmd.env("GIT_SSH_COMMAND", "ssh -F ssh_config"); + cmd.current_dir(base); + + run(&mut cmd, "git clone").await +} + +async fn git_cmd(base: impl AsRef, args: &[&str]) -> Result<(), anyhow::Error> { + let mut cmd = Command::new("git"); + cmd.arg("-C"); + cmd.arg("fake"); + for arg in args { + cmd.arg(arg); + } + + cmd.env("GIT_SSH_COMMAND", "ssh -F ../ssh_config"); + cmd.current_dir(base); + + run(&mut cmd, "git command").await +} + +impl DockerComposeContext { + pub fn url(&self) -> &str { + self.url.as_str() + } + + pub fn remote_git(&self) -> &str { + self.remote_git.as_str() + } + + pub async fn setup_ssh_key(&self, base: &str, key: &str) -> Result<(), anyhow::Error> { + let base_dir = self.test_dir.join(base); + fs::create_dir(&base_dir).await?; + let identity_file = base_dir.join("id_key"); + fs::write(&identity_file, key).await?; + let permissions = Permissions::from_mode(0o600); + fs::set_permissions(&identity_file, permissions).await?; + setup_ssh_file(&base_dir, "localhost", "10022", &identity_file).await?; + Ok(()) + } + + pub async fn git_clone(&self, base: &str) -> Result<(), anyhow::Error> { + git_clone(self.test_dir.join(base)).await + } + + pub fn git_dir(&self, base: &str) -> PathBuf { + self.test_dir.join(base).join("fake") + } + + pub async fn git_cmd(&self, base: &str, args: &[&str]) -> Result<(), anyhow::Error> { + git_cmd(self.test_dir.join(base), args).await + } +} + +impl AsyncTestContext for DockerComposeContext { + async fn setup() -> DockerComposeContext { + let cargo_dir = match env::var("CARGO_MANIFEST_DIR") { + Ok(pathstr) => PathBuf::from(pathstr), + Err(e) => panic!("CARGO_MANIFEST_DIR not set: {e:?}"), + }; + let ctx = DockerComposeContext { + docker_dir: cargo_dir.join("../docker/integration_test"), + test_dir: testdir!(), + url: "http://localhost:18000".to_string(), + remote_git: "ssh://git@remote_git/srv/git/fake.git".to_string(), + }; + + // Build githook, needs to use musl to work with the rockstorm/git-server image + { + let mut cmd = Command::new("cargo"); + cmd.arg("build"); + cmd.arg("--target=x86_64-unknown-linux-musl"); + cmd.arg("--package"); + cmd.arg("eyeballs-githook"); + + cmd.current_dir(cargo_dir); + + run(&mut cmd, "cargo build eyeballs-githook") + .await + .expect("cargo build"); + } + + // Start docker compose up + { + let mut cmd = Command::new("docker"); + cmd.arg("compose"); + cmd.arg("up"); + // Build images before starting containers + cmd.arg("--build"); + // Recreate anonymous volumes instead of retrieving data from the previous containers + cmd.arg("--renew-anon-volumes"); + // Detached mode: Run containers in the background + cmd.arg("--detach"); + // Wait for services to be running|healthy. Implies detached mode + cmd.arg("--wait"); + // Assume "yes" as answer to all prompts and run non-interactively + cmd.arg("-y"); + + cmd.current_dir(&ctx.docker_dir); + + run(&mut cmd, "docker compose up") + .await + .expect("docker compose up"); + } + + let mod_path = ctx.test_dir.join("mod"); + fs::create_dir(&mod_path).await.expect("create mod"); + setup_ssh_file( + &mod_path, + "localhost", + "12222", + ctx.docker_dir.join("web/gitkey"), + ) + .await + .expect("ssh_config for remote_git"); + + // Setup fake remote repo + { + let mut cmd = Command::new("ssh"); + cmd.arg("-F"); + cmd.arg("ssh_config"); + cmd.arg("localhost"); + cmd.arg("mkdir /srv/git/fake.git"); + + cmd.current_dir(&mod_path); + + run(&mut cmd, "mkdir").await.expect("ssh mkdir"); + } + { + let mut cmd = Command::new("ssh"); + cmd.arg("-F"); + cmd.arg("ssh_config"); + cmd.arg("localhost"); + cmd.arg("git-init --bare --initial-branch=main /srv/git/fake.git"); + + cmd.current_dir(&mod_path); + + run(&mut cmd, "git-init").await.expect("ssh git-init"); + } + + git_clone(&mod_path).await.expect("git clone"); + + fs::write(mod_path.join("fake/README"), "Hello fellow fake person!") + .await + .expect("Write README"); + + git_cmd(&mod_path, &["add", "README"]) + .await + .expect("git add README"); + git_cmd(&mod_path, &["commit", "-m", "Initial commit"]) + .await + .expect("git commit README"); + git_cmd(&mod_path, &["push", "origin", "HEAD:main"]) + .await + .expect("git push"); + + ctx + } + + async fn teardown(self) { + let mut cmd = Command::new("docker"); + cmd.arg("compose"); + cmd.arg("down"); + // Remove named volumes declared in the "volumes" section of the Compose file and anonymous + // volumes attached to containers + cmd.arg("--volumes"); + + cmd.current_dir(&self.docker_dir); + + run(&mut cmd, "docker compose down") + .await + .expect("docker compose down"); + } +} + +pub fn create_client() -> Result { + Ok(Client::builder().cookie_store(true).build()?) +} + +pub async fn login( + ctx: &mut DockerComposeContext, + client: &mut Client, + username: &str, + password: &str, +) -> Result<(), anyhow::Error> { + let mut params = HashMap::new(); + params.insert("username", username); + params.insert("password", password); + let result = client + .post(format!("{}/api/v1/login", ctx.url())) + .form(¶ms) + .send() + .await?; + if result.status().is_success() { + Ok(()) + } else { + let content = result.text().await?; + Err(anyhow::Error::msg(content)) + } +} + +pub async fn user_key_add( + ctx: &mut DockerComposeContext, + client: &mut Client, + kind: &str, + data: &str, +) -> Result { + let data = api_model::UserKeyData { + kind, + data, + comment: None, + }; + let result = client + .post(format!("{}/api/v1/user/keys/add", ctx.url())) + .json(&data) + .send() + .await?; + if result.status().is_success() { + let project = result.json::().await?; + Ok(project) + } else { + let content = result.text().await?; + Err(anyhow::Error::msg(content)) + } +} + +pub async fn create_project( + ctx: &mut DockerComposeContext, + client: &mut Client, + projectid: &str, + remote: &str, +) -> Result { + let data = api_model::ProjectData { + title: None, + description: None, + remote: Some(remote), + main_branch: None, + }; + let result = client + .post(format!("{}/api/v1/project/{projectid}/new", ctx.url())) + .json(&data) + .send() + .await?; + if result.status().is_success() { + let project = result.json::().await?; + Ok(project) + } else { + let content = result.text().await?; + Err(anyhow::Error::msg(content)) + } +} + +pub async fn list_reviews( + ctx: &mut DockerComposeContext, + client: &mut Client, + projectid: &str, +) -> Result { + let result = client + .get(format!("{}/api/v1/project/{projectid}/reviews", ctx.url())) + .send() + .await?; + if result.status().is_success() { + let project = result.json::().await?; + Ok(project) + } else { + let content = result.text().await?; + Err(anyhow::Error::msg(content)) + } +} diff --git a/server/tests/integration_test.rs b/server/tests/integration_test.rs new file mode 100644 index 0000000..242655b --- /dev/null +++ b/server/tests/integration_test.rs @@ -0,0 +1,128 @@ +use std::thread::sleep; +use std::time::Duration; +use test_context::test_context; +use tokio::fs; + +mod common; + +const TESTKEY1: &str = "-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAlwAAAAdzc2gtcn +NhAAAAAwEAAQAAAIEAspbADQDeHyGkqeo6WLoPcEJ6+2B5X94cJUMopqNdh9Kee2YJGW5+ +PiUTPj2g/7fGk0zZkoXE3VxheKBdsRY8QuX/LsZdFBkC5OOWCWfB14mJKthPgGWlL9gybV +HyTHTVmhkBD3puhVMllUWHLq21sY3jdj4aon8rZNpHLD8mVmsAAAIIcJ0+zHCdPswAAAAH +c3NoLXJzYQAAAIEAspbADQDeHyGkqeo6WLoPcEJ6+2B5X94cJUMopqNdh9Kee2YJGW5+Pi +UTPj2g/7fGk0zZkoXE3VxheKBdsRY8QuX/LsZdFBkC5OOWCWfB14mJKthPgGWlL9gybVHy +THTVmhkBD3puhVMllUWHLq21sY3jdj4aon8rZNpHLD8mVmsAAAADAQABAAAAgHpEtaXxcy +GzQe5G+71lXU6JZXOXQGH/ShvE2B8Gd/GWpIRtfktYF7xqW7tgLEsHQj/0/HzRcs/vAJi6 +iorEY2pwDdSrBdklOZEyRUhvLnuDBrBhFMktZhumZOsKsGXE0ysnyEK8KCPYow7H8azchi +TzHSBGQyRut/y87zU/BT4pAAAAQF3f2MrjYstJot8SVqizkmVzX5SX8XhReCGEpAUeETNF +/inHlEmPl17rr6knzu/fiWC9hmjHfQ/QMgemhik/MmoAAABBAOhHNz7KgIc+4HlQJkAHxA +z/Juixg3nLmAKxar+WvABn1/brN4HmsI3VRvZnChpcsntuS3wm2mywCg1pGaKJPA0AAABB +AMTT22KcAbU6HOpb059GTr8geQaKd84lQOEchEEUkXI/5cxqNq4BjtQNMghaGbYPUwP/4H +syLbjecIEiDAa9JlcAAAANdGhlX2prQHdpbGxvdwECAwQFBg== +-----END OPENSSH PRIVATE KEY----- +"; +const TESTKEY1_PUB: &str = "AAAAB3NzaC1yc2EAAAADAQABAAAAgQCylsANAN4fIa\ +Sp6jpYug9wQnr7YHlf3hwlQyimo12H0p57ZgkZbn4+JRM+PaD/t8aTTNmShcTdXGF4oF2x\ +FjxC5f8uxl0UGQLk45YJZ8HXiYkq2E+AZaUv2DJtUfJMdNWaGQEPem6FUyWVRYcurbWxje\ +N2Phqifytk2kcsPyZWaw=="; +const TESTKEY2: &str = "-----BEGIN OPENSSH PRIVATE KEY----- +b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAlwAAAAdzc2gtcn +NhAAAAAwEAAQAAAIEAsPcgaQGgRevDiPX7lve4AyycMIT8ZcnQ93z1IeIEWlTNzcRofI/8 +7tcvZL0rR/kHLGdbDYE2cfmvVa13cF0wPTPibaJP8vZbpF5s4yvJXLcDpC7gB/kTMQ0b72 +KFL6J/nsQreY8qaq/JNT2XMpHZ7lUHE8cLZO5KsJsImtowQksAAAIIl0WVZZdFlWUAAAAH +c3NoLXJzYQAAAIEAsPcgaQGgRevDiPX7lve4AyycMIT8ZcnQ93z1IeIEWlTNzcRofI/87t +cvZL0rR/kHLGdbDYE2cfmvVa13cF0wPTPibaJP8vZbpF5s4yvJXLcDpC7gB/kTMQ0b72KF +L6J/nsQreY8qaq/JNT2XMpHZ7lUHE8cLZO5KsJsImtowQksAAAADAQABAAAAgEZ1vxPQL+ +5nFu27czcC3uN0qaOv74bfujIwMLIS+cS1q1PYdfnSotS+HQKxR0Ba6P5HELvpzLHIxoUI +klvM3t11M+x6cLmZi4zLQufiwojsBCFFsDwAIW95CW2iNmRyPB4TJwOKKEmnRJnqFCDalk +bb+wOOpCLMCISVqhSVamEhAAAAQQCwcXfOGOJa0MgFiVoU2GQuLAXu4MBA3NWXKsD6gY8q +bZrXdZjEtASFi8BTp7x0FZZNg5VidqLuQrLa+u38KYAUAAAAQQDqqHxXCItVlmU1+iB7mX +Tih/NTiaJykswnAauKIO2X2okPY0pU/S1JSsGbb02pqBrTqGpdiUqESMdhAcoMCp7jAAAA +QQDBD2MOIH7HULFElpj09LYGi+y5Lnhbu4Rn97SIyZiLyYTFMcKhkDtEGF6myTtF9D16U7 +KtQ4lA6EyRX9rgP4N5AAAADXRoZV9qa0B3aWxsb3cBAgMEBQ== +-----END OPENSSH PRIVATE KEY----- +"; +const TESTKEY2_PUB: &str = "AAAAB3NzaC1yc2EAAAADAQABAAAAgQCw9yBpAaBF68\ +OI9fuW97gDLJwwhPxlydD3fPUh4gRaVM3NxGh8j/zu1y9kvStH+QcsZ1sNgTZx+a9VrXdw\ +XTA9M+Jtok/y9lukXmzjK8lctwOkLuAH+RMxDRvvYoUvon+exCt5jypqr8k1PZcykdnuVQ\ +cTxwtk7kqwmwia2jBCSw=="; + +#[test_context(common::DockerComposeContext)] +#[tokio::test] +async fn test_sanity(ctx: &mut common::DockerComposeContext) { + let mut client1 = common::create_client().expect("client1"); + common::login(ctx, &mut client1, "user01", "password1") + .await + .expect("user01 login"); + + common::user_key_add(ctx, &mut client1, "sha-rsa", TESTKEY1_PUB) + .await + .expect("user01 key add"); + ctx.setup_ssh_key("client1", TESTKEY1) + .await + .expect("user01 ssh_config setup"); + + let mut client2 = common::create_client().expect("client2"); + common::login(ctx, &mut client2, "user02", "password2") + .await + .expect("user02 login"); + + common::user_key_add(ctx, &mut client2, "sha-rsa", TESTKEY2_PUB) + .await + .expect("user02 key add"); + ctx.setup_ssh_key("client2", TESTKEY2) + .await + .expect("user02 ssh_config setup"); + + let remote_git = String::from(ctx.remote_git()); + common::create_project(ctx, &mut client1, "fake", &remote_git) + .await + .expect("create fake project"); + + ctx.git_clone("client1").await.expect("git clone user01"); + { + let dir = ctx.git_dir("client1"); + ctx.git_cmd("client1", &["config", "set", "user.name", "John Smith"]) + .await + .expect("config set"); + ctx.git_cmd( + "client1", + &["config", "set", "user.email", "user01@example.org"], + ) + .await + .expect("config set"); + ctx.git_cmd("client1", &["checkout", "-b", "user01/review1"]) + .await + .expect("checkout"); + fs::write(dir.join("README"), "Hello World!") + .await + .expect("rewrite README"); + fs::write(dir.join("empty"), "") + .await + .expect("create empty"); + ctx.git_cmd("client1", &["add", "README", "empty"]) + .await + .expect("git add"); + ctx.git_cmd("client1", &["commit", "-m", "Improve spelling"]) + .await + .expect("git commit"); + ctx.git_cmd( + "client1", + &["push", "--set-upstream", "origin", "user01/review1"], + ) + .await + .expect("git push"); + } + + for _ in 0..5 { + let reviews = common::list_reviews(ctx, &mut client2, "fake") + .await + .expect("list reviews"); + if reviews.reviews.len() > 0 { + assert_eq!(reviews.reviews[0].branch, "user01/review1"); + break; + } + sleep(Duration::from_millis(500)); + } +} -- cgit v1.2.3-70-g09d2