diff --git a/.vscode/settings.json b/.vscode/settings.json index f20575ae9..1208c4cc0 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -7,5 +7,6 @@ "rust-analyzer.cargo.runBuildScripts": true, "[toml]": { "editor.defaultFormatter": "tamasfe.even-better-toml" - } + }, + "rust-analyzer.cargo.features": ["logic-integration-tests"] } diff --git a/Cargo.lock b/Cargo.lock index b69d104c8..80c832e13 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1371,6 +1371,8 @@ name = "facetec-api-client" version = "0.1.0" dependencies = [ "assert_matches", + "async-trait", + "bytes 1.0.1", "reqwest", "serde", "serde_json", @@ -5297,6 +5299,8 @@ dependencies = [ "serde", "tokio 1.8.1", "tracing", + "tracing-test", + "uuid", "warp", ] @@ -8138,6 +8142,29 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "tracing-test" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a3b48778c2d401c6a7fcf38a0e3c55dc8e8e753cbd381044a8cdb6fd69a29f53" +dependencies = [ + "lazy_static", + "tracing-core", + "tracing-subscriber", + "tracing-test-macro", +] + +[[package]] +name = "tracing-test-macro" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c49adbab879d2e0dd7f75edace5f0ac2156939ecb7e6a1e8fa14e53728328c48" +dependencies = [ + "lazy_static", + "quote", + "syn", +] + [[package]] name = "treeline" version = "0.1.0" @@ -8393,6 +8420,15 @@ version = "0.7.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "09cc8ee72d2a9becf2f2febe0205bbed8fc6615b7cb429ad062dc7b7ddd036a9" +[[package]] +name = "uuid" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc5cf98d8186244414c848017f0e2676b3fcb46807f6668a97dfe67359a3c4b7" +dependencies = [ + "getrandom 0.2.3", +] + [[package]] name = "value-bag" version = "1.0.0-alpha.7" diff --git a/crates/facetec-api-client/Cargo.toml b/crates/facetec-api-client/Cargo.toml index b6f776b40..7f76551d3 100644 --- a/crates/facetec-api-client/Cargo.toml +++ b/crates/facetec-api-client/Cargo.toml @@ -6,8 +6,11 @@ authors = ["Humanode Team "] publish = false [dependencies] +async-trait = "0.1" +bytes = "1" reqwest = { version = "0.11", features = ["json"] } serde = { version = "1", features = ["derive"] } +serde_json = "1" thiserror = "1" [dev-dependencies] diff --git a/crates/facetec-api-client/src/db_delete.rs b/crates/facetec-api-client/src/db_delete.rs new file mode 100644 index 000000000..95ed70236 --- /dev/null +++ b/crates/facetec-api-client/src/db_delete.rs @@ -0,0 +1,238 @@ +//! POST `/3d-db/delete` + +use reqwest::StatusCode; +use serde::{Deserialize, Serialize}; + +use super::Client; + +impl Client +where + RBEI: crate::response_body_error::Inspector, +{ + /// Perform the `/3d-db/delete` call to the server. + pub async fn db_delete(&self, req: Request<'_>) -> Result> { + let res = self.build_post("/3d-db/delete", &req).send().await?; + match res.status() { + StatusCode::OK => Ok(self.parse_json(res).await?), + StatusCode::BAD_REQUEST => Err(crate::Error::Call(Error::BadRequest( + self.parse_json(res).await?, + ))), + _ => Err(crate::Error::Call(Error::Unknown(res.text().await?))), + } + } +} + +/// Input data for the `/3d-db/delete` request. +#[derive(Debug, Serialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Request<'a> { + /// The ID of the enrolled FaceMap to delete. + pub identifier: &'a str, + /// The name of the group to delete the specified FaceMap from. + pub group_name: &'a str, +} + +/// The response from `/3d-db/delete`. +#[derive(Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Response { + /// Whether the request was successful. + pub success: bool, +} + +/// The `/3d-db/delete`-specific error kind. +#[derive(thiserror::Error, Debug, PartialEq)] +pub enum Error { + /// Bad request error occured. + #[error("bad request: {0}")] + BadRequest(ErrorBadRequest), + /// Some other error occured. + #[error("unknown error: {0}")] + Unknown(String), +} + +/// The error kind for the `/3d-db/delete`-specific 400 response. +#[derive(thiserror::Error, Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +#[error("bad request: {error_message}")] +pub struct ErrorBadRequest { + /// Whether the request had any errors during the execution. + /// Expected to always be `true` in this context. + pub error: bool, + /// Whether the request was successful. + /// Expected to always be `false` in this context. + pub success: bool, + /// The error message. + pub error_message: String, +} + +#[cfg(test)] +mod tests { + use wiremock::{matchers, Mock, MockServer, ResponseTemplate}; + + use crate::tests::test_client; + + use super::*; + + #[test] + fn request_serialization() { + let expected_request = serde_json::json!({ + "identifier": "my_test_id", + "groupName": "" + }); + + let actual_request = serde_json::to_value(&Request { + identifier: "my_test_id", + group_name: "", + }) + .unwrap(); + + assert_eq!(expected_request, actual_request); + } + + #[test] + fn response_deserialization() { + let sample_response = serde_json::json!({ + "additionalSessionData": { + "isAdditionalDataPartiallyIncomplete": true + }, + "callData": { + "tid": "0haAzpKGLfc4fa345-ee26-11eb-86b0-0232fd4aba88", + "path": "/3d-db/delete", + "date": "Jul 26, 2021 15:34:37 PM", + "epochSecond": 1627313677, + "requestMethod": "POST" + }, + "error": false, + "serverInfo": { + "version": "9.3.1-dev-2021070201", + "mode": "Development Only", + "notice": "You should only be reading this if you are in server-side code. Please make sure you do not allow the FaceTec Server to be called from the public internet." + }, + "success": true + }); + + let response: Response = serde_json::from_value(sample_response).unwrap(); + assert_matches!(response, Response { success: true }) + } + #[test] + fn bad_request_error_response_deserialization() { + let sample_response = serde_json::json!({ + "error": true, + "errorMessage": "No entry found in the database.", + "success": false + }); + + let response: ErrorBadRequest = serde_json::from_value(sample_response).unwrap(); + assert_eq!( + response, + ErrorBadRequest { + error: true, + success: false, + error_message: "No entry found in the database.".to_owned(), + } + ) + } + + #[tokio::test] + async fn mock_success() { + let mock_server = MockServer::start().await; + + let sample_request = Request { + identifier: "my_test_id", + group_name: "", + }; + let sample_response = serde_json::json!({ + "additionalSessionData": { + "isAdditionalDataPartiallyIncomplete": true + }, + "callData": { + "tid": "0haAzpKGLfc4fa345-ee26-11eb-86b0-0232fd4aba88", + "path": "/3d-db/delete", + "date": "Jul 26, 2021 15:34:37 PM", + "epochSecond": 1627313677, + "requestMethod": "POST" + }, + "error": false, + "serverInfo": { + "version": "9.3.1-dev-2021070201", + "mode": "Development Only", + "notice": "You should only be reading this if you are in server-side code. Please make sure you do not allow the FaceTec Server to be called from the public internet." + }, + "success": true + }); + + let expected_response: Response = serde_json::from_value(sample_response.clone()).unwrap(); + + Mock::given(matchers::method("POST")) + .and(matchers::path("/3d-db/delete")) + .and(matchers::body_json(&sample_request)) + .respond_with(ResponseTemplate::new(200).set_body_json(&sample_response)) + .mount(&mock_server) + .await; + + let client = test_client(mock_server.uri()); + + let actual_response = client.db_delete(sample_request).await.unwrap(); + assert_eq!(actual_response, expected_response); + } + + #[tokio::test] + async fn mock_error_unknown() { + let mock_server = MockServer::start().await; + + let sample_request = Request { + identifier: "my_test_id", + group_name: "", + }; + let sample_response = "Some error text"; + + Mock::given(matchers::method("POST")) + .and(matchers::path("/3d-db/delete")) + .and(matchers::body_json(&sample_request)) + .respond_with(ResponseTemplate::new(500).set_body_string(sample_response)) + .mount(&mock_server) + .await; + + let client = test_client(mock_server.uri()); + + let actual_error = client.db_delete(sample_request).await.unwrap_err(); + assert_matches!( + actual_error, + crate::Error::Call(Error::Unknown(error_text)) if error_text == sample_response + ); + } + + #[tokio::test] + async fn mock_error_bad_request() { + let mock_server = MockServer::start().await; + + let sample_request = Request { + identifier: "my_test_id", + group_name: "", + }; + let sample_response = serde_json::json!({ + "error": true, + "errorMessage": "No entry found in the database.", + "success": false + }); + + let expected_error: ErrorBadRequest = + serde_json::from_value(sample_response.clone()).unwrap(); + + Mock::given(matchers::method("POST")) + .and(matchers::path("/3d-db/delete")) + .and(matchers::body_json(&sample_request)) + .respond_with(ResponseTemplate::new(400).set_body_json(&sample_response)) + .mount(&mock_server) + .await; + + let client = test_client(mock_server.uri()); + + let actual_error = client.db_delete(sample_request).await.unwrap_err(); + assert_matches!( + actual_error, + crate::Error::Call(Error::BadRequest(err)) if err == expected_error + ); + } +} diff --git a/crates/facetec-api-client/src/db_enroll.rs b/crates/facetec-api-client/src/db_enroll.rs index b6fd4dcab..0ce3b3376 100644 --- a/crates/facetec-api-client/src/db_enroll.rs +++ b/crates/facetec-api-client/src/db_enroll.rs @@ -3,23 +3,23 @@ use reqwest::StatusCode; use serde::{Deserialize, Serialize}; -use crate::{CommonResponse, Error}; +use crate::CommonResponse; use super::Client; -impl Client { +impl Client +where + RBEI: crate::response_body_error::Inspector, +{ /// Perform the `/3d-db/enroll` call to the server. - pub async fn db_enroll( - &self, - req: DBEnrollRequest<'_>, - ) -> Result> { + pub async fn db_enroll(&self, req: Request<'_>) -> Result> { let res = self.build_post("/3d-db/enroll", &req).send().await?; match res.status() { - StatusCode::OK => Ok(res.json().await?), - StatusCode::BAD_REQUEST => { - Err(Error::Call(DBEnrollError::BadRequest(res.json().await?))) - } - _ => Err(Error::Call(DBEnrollError::Unknown(res.text().await?))), + StatusCode::OK => Ok(self.parse_json(res).await?), + StatusCode::BAD_REQUEST => Err(crate::Error::Call(Error::BadRequest( + self.parse_json(res).await?, + ))), + _ => Err(crate::Error::Call(Error::Unknown(res.text().await?))), } } } @@ -27,7 +27,7 @@ impl Client { /// Input data for the `/3d-db/enroll` request. #[derive(Debug, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] -pub struct DBEnrollRequest<'a> { +pub struct Request<'a> { /// The ID of the pre-enrolled FaceMap to use. #[serde(rename = "externalDatabaseRefID")] pub external_database_ref_id: &'a str, @@ -38,13 +38,10 @@ pub struct DBEnrollRequest<'a> { /// The response from `/3d-db/enroll`. #[derive(Debug, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] -pub struct DBEnrollResponse { +pub struct Response { /// Common response portion. #[serde(flatten)] pub common: CommonResponse, - /// The external database ID that was used. - #[serde(rename = "externalDatabaseRefID")] - pub external_database_ref_id: String, /// Whether the request had any errors during the execution. pub error: bool, /// Whether the request was successful. @@ -52,24 +49,24 @@ pub struct DBEnrollResponse { } /// The `/3d-db/enroll`-specific error kind. -#[derive(Error, Debug, PartialEq)] -pub enum DBEnrollError { +#[derive(thiserror::Error, Debug, PartialEq)] +pub enum Error { /// The face scan or public key were already enrolled. #[error("already enrolled")] AlreadyEnrolled, /// Bad request error occured. #[error("bad request: {0}")] - BadRequest(DBEnrollErrorBadRequest), + BadRequest(ErrorBadRequest), /// Some other error occured. #[error("unknown error: {0}")] Unknown(String), } /// The error kind for the `/3d-db/enroll`-specific 400 response. -#[derive(Error, Debug, Deserialize, PartialEq)] +#[derive(thiserror::Error, Debug, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] #[error("bad request: {error_message}")] -pub struct DBEnrollErrorBadRequest { +pub struct ErrorBadRequest { /// Whether the request had any errors during the execution. /// Expected to always be `true` in this context. pub error: bool, @@ -84,6 +81,8 @@ pub struct DBEnrollErrorBadRequest { mod tests { use wiremock::{matchers, Mock, MockServer, ResponseTemplate}; + use crate::tests::test_client; + use super::*; #[test] @@ -93,7 +92,7 @@ mod tests { "groupName": "" }); - let actual_request = serde_json::to_value(&DBEnrollRequest { + let actual_request = serde_json::to_value(&Request { external_database_ref_id: "my_test_id", group_name: "", }) @@ -105,35 +104,33 @@ mod tests { #[test] fn response_deserialization() { let sample_response = serde_json::json!({ - "additionalSessionData": { - "isAdditionalDataPartiallyIncomplete": true - }, + "success": true, + "wasProcessed": true, "callData": { - "tid": "4uJgQnnkRAW-d737c7a4-ff7e-11ea-8db5-0232fd4aba88", + "tid": "f1f5da70-b23b-44e8-a24e-c0e8c77b5c56", "path": "/3d-db/enroll", - "date": "Sep 25, 2020 22:31:22 PM", - "epochSecond": 1601073082, + "date": "Jul 26, 2021 3:49:24 PM", + "epochSecond": 1627314564, "requestMethod": "POST" }, + "additionalSessionData": { "isAdditionalDataPartiallyIncomplete": true }, "error": false, - "externalDatabaseRefID": "test_external_dbref_id", "serverInfo": { - "version": "9.0.0", + "version": "9.3.0", + "type": "Standard", "mode": "Development Only", "notice": "You should only be reading this if you are in server-side code. Please make sure you do not allow the FaceTec Server to be called from the public internet." - }, - "success": true + } }); - let response: DBEnrollResponse = serde_json::from_value(sample_response).unwrap(); + let response: Response = serde_json::from_value(sample_response).unwrap(); assert_matches!( response, - DBEnrollResponse { - external_database_ref_id, + Response { error: false, success: true, .. - } if external_database_ref_id == "test_external_dbref_id" + } ) } #[test] @@ -144,10 +141,10 @@ mod tests { "success": false }); - let response: DBEnrollErrorBadRequest = serde_json::from_value(sample_response).unwrap(); + let response: ErrorBadRequest = serde_json::from_value(sample_response).unwrap(); assert_eq!( response, - DBEnrollErrorBadRequest { + ErrorBadRequest { error: true, success: false, error_message: "No entry found in the database.".to_owned(), @@ -159,33 +156,31 @@ mod tests { async fn mock_success() { let mock_server = MockServer::start().await; - let sample_request = DBEnrollRequest { + let sample_request = Request { external_database_ref_id: "my_test_id", group_name: "", }; let sample_response = serde_json::json!({ - "additionalSessionData": { - "isAdditionalDataPartiallyIncomplete": true - }, + "success": true, + "wasProcessed": true, "callData": { - "tid": "4uJgQnnkRAW-d737c7a4-ff7e-11ea-8db5-0232fd4aba88", + "tid": "f1f5da70-b23b-44e8-a24e-c0e8c77b5c56", "path": "/3d-db/enroll", - "date": "Sep 25, 2020 22:31:22 PM", - "epochSecond": 1601073082, + "date": "Jul 26, 2021 3:49:24 PM", + "epochSecond": 1627314564, "requestMethod": "POST" }, + "additionalSessionData": { "isAdditionalDataPartiallyIncomplete": true }, "error": false, - "externalDatabaseRefID": "test_external_dbref_id", "serverInfo": { - "version": "9.0.0", + "version": "9.3.0", + "type": "Standard", "mode": "Development Only", "notice": "You should only be reading this if you are in server-side code. Please make sure you do not allow the FaceTec Server to be called from the public internet." - }, - "success": true + } }); - let expected_response: DBEnrollResponse = - serde_json::from_value(sample_response.clone()).unwrap(); + let expected_response: Response = serde_json::from_value(sample_response.clone()).unwrap(); Mock::given(matchers::method("POST")) .and(matchers::path("/3d-db/enroll")) @@ -194,11 +189,7 @@ mod tests { .mount(&mock_server) .await; - let client = Client { - base_url: mock_server.uri(), - reqwest: reqwest::Client::new(), - device_key_identifier: "my device key identifier".into(), - }; + let client = test_client(mock_server.uri()); let actual_response = client.db_enroll(sample_request).await.unwrap(); assert_eq!(actual_response, expected_response); @@ -208,7 +199,7 @@ mod tests { async fn mock_error_unknown() { let mock_server = MockServer::start().await; - let sample_request = DBEnrollRequest { + let sample_request = Request { external_database_ref_id: "my_test_id", group_name: "", }; @@ -221,16 +212,12 @@ mod tests { .mount(&mock_server) .await; - let client = Client { - base_url: mock_server.uri(), - reqwest: reqwest::Client::new(), - device_key_identifier: "my device key identifier".into(), - }; + let client = test_client(mock_server.uri()); let actual_error = client.db_enroll(sample_request).await.unwrap_err(); assert_matches!( actual_error, - Error::Call(DBEnrollError::Unknown(error_text)) if error_text == sample_response + crate::Error::Call(Error::Unknown(error_text)) if error_text == sample_response ); } @@ -238,7 +225,7 @@ mod tests { async fn mock_error_bad_request() { let mock_server = MockServer::start().await; - let sample_request = DBEnrollRequest { + let sample_request = Request { external_database_ref_id: "my_test_id", group_name: "", }; @@ -248,7 +235,7 @@ mod tests { "success": false }); - let expected_error: DBEnrollErrorBadRequest = + let expected_error: ErrorBadRequest = serde_json::from_value(sample_response.clone()).unwrap(); Mock::given(matchers::method("POST")) @@ -258,16 +245,12 @@ mod tests { .mount(&mock_server) .await; - let client = Client { - base_url: mock_server.uri(), - reqwest: reqwest::Client::new(), - device_key_identifier: "my device key identifier".into(), - }; + let client = test_client(mock_server.uri()); let actual_error = client.db_enroll(sample_request).await.unwrap_err(); assert_matches!( actual_error, - Error::Call(DBEnrollError::BadRequest(err)) if err == expected_error + crate::Error::Call(Error::BadRequest(err)) if err == expected_error ); } } diff --git a/crates/facetec-api-client/src/db_search.rs b/crates/facetec-api-client/src/db_search.rs index 33301dbf2..26981500a 100644 --- a/crates/facetec-api-client/src/db_search.rs +++ b/crates/facetec-api-client/src/db_search.rs @@ -3,23 +3,29 @@ use reqwest::StatusCode; use serde::{Deserialize, Serialize}; -use crate::{CommonResponse, Error, MatchLevel}; +use crate::{serde_util::Either, MatchLevel}; use super::Client; -impl Client { +impl Client +where + RBEI: crate::response_body_error::Inspector, +{ /// Perform the `/3d-db/search` call to the server. - pub async fn db_search( - &self, - req: DBSearchRequest<'_>, - ) -> Result> { + pub async fn db_search(&self, req: Request<'_>) -> Result> { let res = self.build_post("/3d-db/search", &req).send().await?; match res.status() { - StatusCode::OK => Ok(res.json().await?), - StatusCode::BAD_REQUEST => { - Err(Error::Call(DBSearchError::BadRequest(res.json().await?))) + StatusCode::OK => { + let body: Either = self.parse_json(res).await?; + match body { + Either::Left(val) => Ok(val), + Either::Right(err) => Err(crate::Error::Call(Error::BadRequest(err))), + } } - _ => Err(Error::Call(DBSearchError::Unknown(res.text().await?))), + StatusCode::BAD_REQUEST => Err(crate::Error::Call(Error::BadRequest( + self.parse_json(res).await?, + ))), + _ => Err(crate::Error::Call(Error::Unknown(res.text().await?))), } } } @@ -27,7 +33,7 @@ impl Client { /// Input data for the `/3d-db/search` request. #[derive(Debug, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] -pub struct DBSearchRequest<'a> { +pub struct Request<'a> { /// The ID of the pre-enrolled FaceMap to search with. #[serde(rename = "externalDatabaseRefID")] pub external_database_ref_id: &'a str, @@ -40,26 +46,17 @@ pub struct DBSearchRequest<'a> { /// The response from `/3d-db/search`. #[derive(Debug, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] -pub struct DBSearchResponse { - /// Common response portion. - #[serde(flatten)] - pub common: CommonResponse, - /// The ID of the pre-enrolled FaceMap that was used for searching - /// as an input. - #[serde(rename = "externalDatabaseRefID")] - pub external_database_ref_id: String, - /// Whether the request had any errors during the execution. - pub error: bool, +pub struct Response { /// Whether the request was successful. pub success: bool, /// The set of all the matched entries enrolled on the group. - pub results: Vec, + pub results: Vec, } /// A single entry that matched the search request. #[derive(Debug, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] -pub struct DBSearchResponseResult { +pub struct ResponseResult { /// The external database ID associated with this entry. pub identifier: String, /// The level of matching this entry funfills to the input FaceMap. @@ -67,21 +64,21 @@ pub struct DBSearchResponseResult { } /// The `/3d-db/search`-specific error kind. -#[derive(Error, Debug, PartialEq)] -pub enum DBSearchError { +#[derive(thiserror::Error, Debug, PartialEq)] +pub enum Error { /// Bad request error occured. #[error("bad request: {0}")] - BadRequest(DBSearchErrorBadRequest), + BadRequest(ErrorBadRequest), /// Some other error occured. #[error("unknown error: {0}")] Unknown(String), } /// The error kind for the `/3d-db/search`-specific 400 response. -#[derive(Error, Debug, Deserialize, PartialEq)] +#[derive(thiserror::Error, Debug, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] #[error("bad request: {error_message}")] -pub struct DBSearchErrorBadRequest { +pub struct ErrorBadRequest { /// Whether the request had any errors during the execution. /// Expected to always be `true` in this context. pub error: bool, @@ -96,6 +93,8 @@ pub struct DBSearchErrorBadRequest { mod tests { use wiremock::{matchers, Mock, MockServer, ResponseTemplate}; + use crate::tests::test_client; + use super::*; #[test] @@ -106,7 +105,7 @@ mod tests { "minMatchLevel": 10, }); - let actual_request = serde_json::to_value(&DBSearchRequest { + let actual_request = serde_json::to_value(&Request { external_database_ref_id: "my_test_id", group_name: "", min_match_level: 10, @@ -145,20 +144,17 @@ mod tests { } }); - let response: DBSearchResponse = serde_json::from_value(sample_response).unwrap(); + let response: Response = serde_json::from_value(sample_response).unwrap(); assert_matches!( response, - DBSearchResponse { - ref external_database_ref_id, - error: false, + Response { success: true, results, .. - } if external_database_ref_id == "test_external_dbref_id" && - results.len() == 1 && + } if results.len() == 1 && matches!( &results[0], - &DBSearchResponseResult{ + &ResponseResult{ ref identifier, match_level: 10, .. @@ -175,10 +171,10 @@ mod tests { "success": false }); - let response: DBSearchErrorBadRequest = serde_json::from_value(sample_response).unwrap(); + let response: ErrorBadRequest = serde_json::from_value(sample_response).unwrap(); assert_eq!( response, - DBSearchErrorBadRequest { + ErrorBadRequest { error: true, success: false, error_message: "No entry found in the database.".to_owned(), @@ -186,11 +182,39 @@ mod tests { ) } + #[test] + fn unexpected_error_in_success_response_deserialization() { + let sample_response = serde_json::json!({ + "errorMessage": "Tried to search a groupName when that groupName does not exist. groupName: humanode. Try adding a 3D FaceMap by calling /3d-db/enroll first.", + "errorToString": "java.lang.Exception: Tried to search a groupName when that groupName does not exist. groupName: humanode. Try adding a 3D FaceMap by calling /3d-db/enroll first.", + "stackTrace": "java.lang.Exception: Tried to search a groupName when that groupName does not exist. groupName: humanode. Try adding a 3D FaceMap by calling /3d-db/enroll first.\\n\\tat com.facetec.standardserver.search.SearchManager.search(SearchManager.java:64)\\n\\tat com.facetec.standardserver.processors.SearchProcessor.processRequest(SearchProcessor.java:35)\\n\\tat com.facetec.standardserver.processors.CommonProcessor.handle(CommonProcessor.java:58)\\n\\tat com.sun.net.httpserver.Filter$Chain.doFilter(Filter.java:79)\\n\\tat sun.net.httpserver.AuthFilter.doFilter(AuthFilter.java:83)\\n\\tat com.sun.net.httpserver.Filter$Chain.doFilter(Filter.java:82)\\n\\tat sun.net.httpserver.ServerImpl$Exchange$LinkHandler.handle(ServerImpl.java:675)\\n\\tat com.sun.net.httpserver.Filter$Chain.doFilter(Filter.java:79)\\n\\tat sun.net.httpserver.ServerImpl$Exchange.run(ServerImpl.java:647)\\n\\tat java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)\\n\\tat java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)\\n\\tat java.lang.Thread.run(Thread.java:748)\\n", + "success": false, + "wasProcessed": true, + "error": true, + "serverInfo": { + "version": "9.3.0", + "type": "Standard", + "mode": "Development Only", + "notice": "You should only be reading this if you are in server-side code. Please make sure you do not allow the FaceTec Server to be called from the public internet." + } + }); + + let response: ErrorBadRequest = serde_json::from_value(sample_response).unwrap(); + assert_eq!( + response, + ErrorBadRequest { + error: true, + success: false, + error_message: "Tried to search a groupName when that groupName does not exist. groupName: humanode. Try adding a 3D FaceMap by calling /3d-db/enroll first.".to_owned(), + } + ) + } + #[tokio::test] async fn mock_success() { let mock_server = MockServer::start().await; - let sample_request = DBSearchRequest { + let sample_request = Request { external_database_ref_id: "my_test_id", group_name: "", min_match_level: 10, @@ -222,8 +246,7 @@ mod tests { } }); - let expected_response: DBSearchResponse = - serde_json::from_value(sample_response.clone()).unwrap(); + let expected_response: Response = serde_json::from_value(sample_response.clone()).unwrap(); Mock::given(matchers::method("POST")) .and(matchers::path("/3d-db/search")) @@ -232,11 +255,7 @@ mod tests { .mount(&mock_server) .await; - let client = Client { - base_url: mock_server.uri(), - reqwest: reqwest::Client::new(), - device_key_identifier: "my device key identifier".into(), - }; + let client = test_client(mock_server.uri()); let actual_response = client.db_search(sample_request).await.unwrap(); assert_eq!(actual_response, expected_response); @@ -246,7 +265,7 @@ mod tests { async fn mock_error_unknown() { let mock_server = MockServer::start().await; - let sample_request = DBSearchRequest { + let sample_request = Request { external_database_ref_id: "my_test_id", group_name: "", min_match_level: 10, @@ -260,16 +279,12 @@ mod tests { .mount(&mock_server) .await; - let client = Client { - base_url: mock_server.uri(), - reqwest: reqwest::Client::new(), - device_key_identifier: "my device key identifier".into(), - }; + let client = test_client(mock_server.uri()); let actual_error = client.db_search(sample_request).await.unwrap_err(); assert_matches!( actual_error, - Error::Call(DBSearchError::Unknown(error_text)) if error_text == sample_response + crate::Error::Call(Error::Unknown(error_text)) if error_text == sample_response ); } @@ -277,7 +292,7 @@ mod tests { async fn mock_error_bad_request() { let mock_server = MockServer::start().await; - let sample_request = DBSearchRequest { + let sample_request = Request { external_database_ref_id: "my_test_id", group_name: "", min_match_level: 10, @@ -288,7 +303,7 @@ mod tests { "success": false }); - let expected_error: DBSearchErrorBadRequest = + let expected_error: ErrorBadRequest = serde_json::from_value(sample_response.clone()).unwrap(); Mock::given(matchers::method("POST")) @@ -298,16 +313,55 @@ mod tests { .mount(&mock_server) .await; - let client = Client { - base_url: mock_server.uri(), - reqwest: reqwest::Client::new(), - device_key_identifier: "my device key identifier".into(), + let client = test_client(mock_server.uri()); + + let actual_error = client.db_search(sample_request).await.unwrap_err(); + assert_matches!( + actual_error, + crate::Error::Call(Error::BadRequest(err)) if err == expected_error + ); + } + + #[tokio::test] + async fn mock_error_bad_request_in_success() { + let mock_server = MockServer::start().await; + + let sample_request = Request { + external_database_ref_id: "my_test_id", + group_name: "humanode", + min_match_level: 10, }; + let sample_response = serde_json::json!({ + "errorMessage": "Tried to search a groupName when that groupName does not exist. groupName: humanode. Try adding a 3D FaceMap by calling /3d-db/enroll first.", + "errorToString": "java.lang.Exception: Tried to search a groupName when that groupName does not exist. groupName: humanode. Try adding a 3D FaceMap by calling /3d-db/enroll first.", + "stackTrace": "java.lang.Exception: Tried to search a groupName when that groupName does not exist. groupName: humanode. Try adding a 3D FaceMap by calling /3d-db/enroll first.\\n\\tat com.facetec.standardserver.search.SearchManager.search(SearchManager.java:64)\\n\\tat com.facetec.standardserver.processors.SearchProcessor.processRequest(SearchProcessor.java:35)\\n\\tat com.facetec.standardserver.processors.CommonProcessor.handle(CommonProcessor.java:58)\\n\\tat com.sun.net.httpserver.Filter$Chain.doFilter(Filter.java:79)\\n\\tat sun.net.httpserver.AuthFilter.doFilter(AuthFilter.java:83)\\n\\tat com.sun.net.httpserver.Filter$Chain.doFilter(Filter.java:82)\\n\\tat sun.net.httpserver.ServerImpl$Exchange$LinkHandler.handle(ServerImpl.java:675)\\n\\tat com.sun.net.httpserver.Filter$Chain.doFilter(Filter.java:79)\\n\\tat sun.net.httpserver.ServerImpl$Exchange.run(ServerImpl.java:647)\\n\\tat java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)\\n\\tat java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)\\n\\tat java.lang.Thread.run(Thread.java:748)\\n", + "success": false, + "wasProcessed": true, + "error": true, + "serverInfo": { + "version": "9.3.0", + "type": "Standard", + "mode": "Development Only", + "notice": "You should only be reading this if you are in server-side code. Please make sure you do not allow the FaceTec Server to be called from the public internet." + } + }); + + let expected_error: ErrorBadRequest = + serde_json::from_value(sample_response.clone()).unwrap(); + + Mock::given(matchers::method("POST")) + .and(matchers::path("/3d-db/search")) + .and(matchers::body_json(&sample_request)) + .respond_with(ResponseTemplate::new(200).set_body_json(&sample_response)) + .mount(&mock_server) + .await; + + let client = test_client(mock_server.uri()); let actual_error = client.db_search(sample_request).await.unwrap_err(); assert_matches!( actual_error, - Error::Call(DBSearchError::BadRequest(err)) if err == expected_error + crate::Error::Call(Error::BadRequest(err)) if err == expected_error ); } } diff --git a/crates/facetec-api-client/src/enrollment3d.rs b/crates/facetec-api-client/src/enrollment3d.rs index c5c3a1d36..3d7258628 100644 --- a/crates/facetec-api-client/src/enrollment3d.rs +++ b/crates/facetec-api-client/src/enrollment3d.rs @@ -3,23 +3,20 @@ use reqwest::StatusCode; use serde::{Deserialize, Serialize}; -use crate::{CommonResponse, Error, FaceScanResponse, OpaqueBase64DataRef, ServerInfo}; +use crate::{CommonResponse, FaceScanResponse, OpaqueBase64DataRef}; use super::Client; -impl Client { +impl Client +where + RBEI: crate::response_body_error::Inspector, +{ /// Perform the `/enrollment-3d` call to the server. - pub async fn enrollment_3d( - &self, - req: Enrollment3DRequest<'_>, - ) -> Result> { + pub async fn enrollment_3d(&self, req: Request<'_>) -> Result> { let res = self.build_post("/enrollment-3d", &req).send().await?; match res.status() { - StatusCode::OK => Ok(res.json().await?), - StatusCode::BAD_REQUEST => Err(Error::Call(Enrollment3DError::BadRequest( - res.json().await?, - ))), - _ => Err(Error::Call(Enrollment3DError::Unknown(res.text().await?))), + StatusCode::OK => Ok(self.parse_json(res).await?), + _ => Err(crate::Error::Call(Error::Unknown(res.text().await?))), } } } @@ -27,7 +24,7 @@ impl Client { /// Input data for the `/enrollment-3d` request. #[derive(Debug, Serialize, PartialEq)] #[serde(rename_all = "camelCase")] -pub struct Enrollment3DRequest<'a> { +pub struct Request<'a> { /// The ID that the FaceTec Server will associate the data with. #[serde(rename = "externalDatabaseRefID")] pub external_database_ref_id: &'a str, @@ -40,52 +37,37 @@ pub struct Enrollment3DRequest<'a> { } /// The response from `/enrollment-3d`. +/// The schema for this particular call if fucked beyound belief; without a proper API docs from +/// the FaceTec side, implemeting this properly will be a waste of time, and error prone. +/// Plus, even the spec won't help - they need to fix thier approach to the API design. #[derive(Debug, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] -pub struct Enrollment3DResponse { +pub struct Response { /// Common response portion. #[serde(flatten)] - pub common: CommonResponse, + pub common: Option, /// FaceScan response portion. #[serde(flatten)] - pub face_scan: FaceScanResponse, + pub face_scan: Option, /// The external database ID that was associated with this item. #[serde(rename = "externalDatabaseRefID")] - pub external_database_ref_id: String, + pub external_database_ref_id: Option, /// Whether the request had any errors during the execution. pub error: bool, /// Whether the request was successful. pub success: bool, + /// Potential error message. + pub error_message: Option, } /// The `/enrollment-3d`-specific error kind. -#[derive(Error, Debug, PartialEq)] -pub enum Enrollment3DError { - /// Bad request error occured. - #[error("bad request: {0}")] - BadRequest(Enrollment3DErrorBadRequest), +#[derive(thiserror::Error, Debug, PartialEq)] +pub enum Error { /// Some other error occured. #[error("unknown error: {0}")] Unknown(String), } -/// The error kind for the `/enrollment-3d`-specific 400 response. -#[derive(Error, Debug, Deserialize, PartialEq)] -#[serde(rename_all = "camelCase")] -#[error("bad request: {error_message}")] -pub struct Enrollment3DErrorBadRequest { - /// The information about the server. - pub server_info: ServerInfo, - /// Whether the request had any errors during the execution. - /// Expected to always be `true` in this context. - pub error: bool, - /// Whether the request was successful. - /// Expected to always be `false` in this context. - pub success: bool, - /// The error message. - pub error_message: String, -} - #[cfg(test)] mod tests { use wiremock::{ @@ -93,7 +75,9 @@ mod tests { Mock, MockServer, ResponseTemplate, }; - use crate::{AdditionalSessionData, CallData, ServerInfo}; + use crate::{ + tests::test_client, AdditionalSessionData, CallData, FaceScanSecurityChecks, ServerInfo, + }; use super::*; @@ -106,7 +90,7 @@ mod tests { "lowQualityAuditTrailImage": "789" }); - let actual_request = serde_json::to_value(&Enrollment3DRequest { + let actual_request = serde_json::to_value(&Request { external_database_ref_id: "my_test_id", face_scan: "123", audit_trail_image: "456", @@ -118,7 +102,7 @@ mod tests { } #[test] - fn response_deserialization() { + fn success_response_deserialization() { let sample_response = serde_json::json!({ "additionalSessionData": { "isAdditionalDataPartiallyIncomplete": false, @@ -143,7 +127,7 @@ mod tests { "externalDatabaseRefID": "test_external_dbref_id", "faceScanSecurityChecks": { "auditTrailVerificationCheckSucceeded": true, - "faceScanLivenessCheckSucceeded": false, + "faceScanLivenessCheckSucceeded": true, "replayCheckSucceeded": true, "sessionTokenCheckSucceeded": true }, @@ -154,21 +138,22 @@ mod tests { "mode": "Development Only", "notice": "Notice" }, - "success": false + "success": true }); - let response: Enrollment3DResponse = serde_json::from_value(sample_response).unwrap(); + let response: Response = serde_json::from_value(sample_response).unwrap(); assert_matches!( response, - Enrollment3DResponse { - external_database_ref_id, + Response { + external_database_ref_id: Some(external_database_ref_id), + success: true, error: false, - success: false, - face_scan: FaceScanResponse { + error_message: None, + face_scan: Some(FaceScanResponse { age_estimate_group_enum_int: -1, .. - }, - common: CommonResponse { + }), + common: Some(CommonResponse { additional_session_data: AdditionalSessionData { is_additional_data_partially_incomplete: false, .. @@ -176,15 +161,19 @@ mod tests { call_data: CallData { .. }, + server_info: ServerInfo { + version: _, + mode:_, + notice:_, + }, .. - }, - .. + }), } if external_database_ref_id == "test_external_dbref_id" ) } #[test] - fn bad_request_error_response_deserialization() { + fn already_enrolled_response_deserialization() { let sample_response = serde_json::json!({ "error": true, "errorMessage": "An enrollment already exists for this externalDatabaseRefID.", @@ -196,20 +185,72 @@ mod tests { } }); - let response: Enrollment3DErrorBadRequest = - serde_json::from_value(sample_response).unwrap(); - assert_eq!( + let response: Response = serde_json::from_value(sample_response).unwrap(); + assert_matches!( response, - Enrollment3DErrorBadRequest { + Response { + external_database_ref_id: None, + error_message: Some(error_message), error: true, success: false, - server_info: ServerInfo { - version: "9.0.0-SNAPSHOT".to_owned(), - mode: "Development Only".to_owned(), - notice: "You should only be reading this if you are in server-side code. Please make sure you do not allow the FaceTec Server to be called from the public internet.".to_owned(), - }, - error_message: "An enrollment already exists for this externalDatabaseRefID.".to_owned(), + face_scan: None, + common: None, + } if error_message == "An enrollment already exists for this externalDatabaseRefID." + ) + } + + #[test] + fn real_world_1_response_deserialization() { + let sample_response = serde_json::json!({ + "faceScanSecurityChecks": { + "replayCheckSucceeded": false, + "sessionTokenCheckSucceeded": true, + "auditTrailVerificationCheckSucceeded": true, + "faceScanLivenessCheckSucceeded": true + }, + "ageEstimateGroupEnumInt": 2, + "externalDatabaseRefID": "qwe", + "retryScreenEnumInt": 0, + "scanResultBlob": "AQEAAABCAAAAAAAAABod8Ab2TBI4O9XmVyim3AxlDaV4QoP2eFBAmQTkB2dOiL4becto+NXWqUxdo6JBjSUoreo9Lm7MToQFpqj/HB+Hzw\\u003d\\u003d", + "success": false, + "wasProcessed": true, + "callData": { + "tid": "bd987975-4fbb-441e-b59a-b26b5fd5987b", + "path": "/enrollment-3d", + "date": "Jul 3, 2021 5:21:16 PM", + "epochSecond": 1625332876, + "requestMethod": "POST" + }, + "additionalSessionData": { "isAdditionalDataPartiallyIncomplete": true }, + "error": false, + "serverInfo": { + "version": "9.3.0", + "type": "Standard", + "mode": "Development Only", + "notice": "You should only be reading this if you are in server-side code. Please make sure you do not allow the FaceTec Server to be called from the public internet." } + }); + + let response: Response = serde_json::from_value(sample_response).unwrap(); + assert_matches!( + response, + Response { + external_database_ref_id: Some(external_database_ref_id), + error_message: None, + error: false, + success: false, + face_scan: Some(FaceScanResponse { + face_scan_security_checks: FaceScanSecurityChecks { + audit_trail_verification_check_succeeded: true, + face_scan_liveness_check_succeeded: true, + replay_check_succeeded: false, + session_token_check_succeeded: true, + }, + retry_screen_enum_int: 0, + age_estimate_group_enum_int: 2, + }), + common: Some(_), + } if external_database_ref_id == "qwe" ) } @@ -217,7 +258,7 @@ mod tests { async fn mock_success() { let mock_server = MockServer::start().await; - let sample_request = Enrollment3DRequest { + let sample_request = Request { external_database_ref_id: "my_test_id", face_scan: "123", audit_trail_image: "456", @@ -261,8 +302,7 @@ mod tests { "success": false }); - let expected_response: Enrollment3DResponse = - serde_json::from_value(sample_response.clone()).unwrap(); + let expected_response: Response = serde_json::from_value(sample_response.clone()).unwrap(); Mock::given(matchers::method("POST")) .and(matchers::path("/enrollment-3d")) @@ -271,89 +311,73 @@ mod tests { .mount(&mock_server) .await; - let client = Client { - base_url: mock_server.uri(), - reqwest: reqwest::Client::new(), - device_key_identifier: "my device key identifier".into(), - }; + let client = test_client(mock_server.uri()); let actual_response = client.enrollment_3d(sample_request).await.unwrap(); assert_eq!(actual_response, expected_response); } #[tokio::test] - async fn mock_error_unknown() { + async fn mock_already_enrolled_error() { let mock_server = MockServer::start().await; - let sample_request = Enrollment3DRequest { + let sample_request = Request { external_database_ref_id: "my_test_id", face_scan: "123", audit_trail_image: "456", low_quality_audit_trail_image: "789", }; - let sample_response = "Some error text"; + let sample_response = serde_json::json!({ + "error": true, + "errorMessage": "An enrollment already exists for this externalDatabaseRefID.", + "success": false, + "serverInfo": { + "version": "9.0.0-SNAPSHOT", + "mode": "Development Only", + "notice": "You should only be reading this if you are in server-side code. Please make sure you do not allow the FaceTec Server to be called from the public internet." + } + }); + + let expected_response: Response = serde_json::from_value(sample_response.clone()).unwrap(); Mock::given(matchers::method("POST")) .and(matchers::path("/enrollment-3d")) .and(matchers::body_json(&sample_request)) - .respond_with(ResponseTemplate::new(500).set_body_string(sample_response)) + .respond_with(ResponseTemplate::new(200).set_body_json(&sample_response)) .mount(&mock_server) .await; - let client = Client { - base_url: mock_server.uri(), - reqwest: reqwest::Client::new(), - device_key_identifier: "my device key identifier".into(), - }; + let client = test_client(mock_server.uri()); - let actual_error = client.enrollment_3d(sample_request).await.unwrap_err(); - assert_matches!( - actual_error, - Error::Call(Enrollment3DError::Unknown(error_text)) if error_text == sample_response - ); + let actual_response = client.enrollment_3d(sample_request).await.unwrap(); + assert_eq!(actual_response, expected_response); } #[tokio::test] - async fn mock_error_bad_request() { + async fn mock_error_unknown() { let mock_server = MockServer::start().await; - let sample_request = Enrollment3DRequest { + let sample_request = Request { external_database_ref_id: "my_test_id", face_scan: "123", audit_trail_image: "456", low_quality_audit_trail_image: "789", }; - let sample_response = serde_json::json!({ - "error": true, - "errorMessage": "An enrollment already exists for this externalDatabaseRefID.", - "success": false, - "serverInfo": { - "version": "9.0.0-SNAPSHOT", - "mode": "Development Only", - "notice": "You should only be reading this if you are in server-side code. Please make sure you do not allow the FaceTec Server to be called from the public internet." - } - }); - - let expected_error: Enrollment3DErrorBadRequest = - serde_json::from_value(sample_response.clone()).unwrap(); + let sample_response = "Some error text"; Mock::given(matchers::method("POST")) .and(matchers::path("/enrollment-3d")) .and(matchers::body_json(&sample_request)) - .respond_with(ResponseTemplate::new(400).set_body_json(&sample_response)) + .respond_with(ResponseTemplate::new(500).set_body_string(sample_response)) .mount(&mock_server) .await; - let client = Client { - base_url: mock_server.uri(), - reqwest: reqwest::Client::new(), - device_key_identifier: "my device key identifier".into(), - }; + let client = test_client(mock_server.uri()); let actual_error = client.enrollment_3d(sample_request).await.unwrap_err(); assert_matches!( actual_error, - Error::Call(Enrollment3DError::BadRequest(err)) if err == expected_error + crate::Error::Call(Error::Unknown(error_text)) if error_text == sample_response ); } } diff --git a/crates/facetec-api-client/src/lib.rs b/crates/facetec-api-client/src/lib.rs index 993c3c165..4b94debf9 100644 --- a/crates/facetec-api-client/src/lib.rs +++ b/crates/facetec-api-client/src/lib.rs @@ -10,19 +10,25 @@ #[macro_use] extern crate assert_matches; -use reqwest::RequestBuilder; +use reqwest::{RequestBuilder, Response}; +use serde::de::DeserializeOwned; use thiserror::Error; -mod db_enroll; -mod db_search; -mod enrollment3d; -mod session_token; +pub mod db_delete; +pub mod db_enroll; +pub mod db_search; +pub mod enrollment3d; +pub mod reset; +pub mod response_body_error; +pub mod serde_util; +pub mod session_token; + mod types; -pub use db_enroll::*; -pub use db_search::*; -pub use enrollment3d::*; -pub use session_token::*; +#[cfg(test)] +mod tests; + +pub use response_body_error::ResponseBodyError; pub use types::*; /// The generic error type for the client calls. @@ -31,6 +37,9 @@ pub enum Error { /// A call-specific error. #[error("server error: {0}")] Call(T), + /// An error due to failure to load or parse the response body. + #[error(transparent)] + ResponseBody(#[from] ResponseBodyError), /// An error coming from the underlying reqwest layer. #[error("reqwest error: {0}")] Reqwest(#[from] reqwest::Error), @@ -38,16 +47,20 @@ pub enum Error { /// The robonode client. #[derive(Debug)] -pub struct Client { +pub struct Client { /// Underyling HTTP client used to execute network calls. pub reqwest: reqwest::Client, /// The base URL to use for the routes. pub base_url: String, /// The Device Key Identifier to pass in the header. pub device_key_identifier: String, + /// The fake IP address to inject via `X-FT-IPAddress` header. + pub injected_ip_address: Option, + /// The inspector for the response body. + pub response_body_error_inspector: RBEI, } -impl Client { +impl Client { /// Prepare the URL. fn build_url(&self, path: &str) -> String { format!("{}{}", self.base_url, path) @@ -55,14 +68,29 @@ impl Client { /// Apply some common headers. fn apply_headers(&self, req: RequestBuilder) -> RequestBuilder { - req.header("X-Device-Key", self.device_key_identifier.clone()) + let req = req.header("X-Device-Key", self.device_key_identifier.clone()); + + if let Some(ref injected_ip_address) = self.injected_ip_address { + req.header("X-FT-IPAddress", injected_ip_address.clone()) + } else { + req + } + } + + /// An internal utility to prepare an HTTP request. + /// Applies some common logic. + fn build(&self, path: &str, f: F) -> RequestBuilder + where + F: FnOnce(String) -> RequestBuilder, + { + let url = self.build_url(path); + self.apply_headers(f(url)) } /// An internal utility to prepare a GET HTTP request. /// Applies some common logic. fn build_get(&self, path: &str) -> RequestBuilder { - let url = self.build_url(path); - self.apply_headers(self.reqwest.get(url)) + self.build(path, |url| self.reqwest.get(url)) } /// An internal utility to prepare a POST HTTP request. @@ -71,7 +99,33 @@ impl Client { where T: serde::Serialize + ?Sized, { - let url = self.build_url(path); - self.apply_headers(self.reqwest.post(url)).json(body) + self.build(path, |url| self.reqwest.post(url)).json(body) + } +} + +impl Client +where + RBEI: response_body_error::Inspector, +{ + /// A custom JSON parsing logic for more control over how we handle JSON parsing errors. + async fn parse_json(&self, res: Response) -> Result + where + T: DeserializeOwned, + { + let full = res.bytes().await.map_err(ResponseBodyError::BodyRead)?; + + self.response_body_error_inspector.inspect_raw(&full).await; + + match serde_json::from_slice(&full) { + Ok(val) => Ok(val), + Err(err) => { + let err = ResponseBodyError::Json { + source: err, + body: full, + }; + self.response_body_error_inspector.inspect_error(&err).await; + Err(err) + } + } } } diff --git a/crates/facetec-api-client/src/reset.rs b/crates/facetec-api-client/src/reset.rs new file mode 100644 index 000000000..8e43e0e46 --- /dev/null +++ b/crates/facetec-api-client/src/reset.rs @@ -0,0 +1,145 @@ +//! DELETE `/delete-database-if-less-than-10-records` + +use reqwest::StatusCode; +use serde::Deserialize; + +use super::Client; + +impl Client +where + RBEI: crate::response_body_error::Inspector, +{ + /// Perform the `/delete-database-if-less-than-10-records` call to the server. + pub async fn reset(&self) -> Result> { + let res = self + .build("/delete-database-if-less-than-10-records", |url| { + self.reqwest.delete(url) + }) + .body(&b"1"[..]) + .send() + .await?; + match res.status() { + StatusCode::OK => Ok(self.parse_json(res).await?), + _ => Err(crate::Error::Call(Error::Unknown(res.text().await?))), + } + } +} + +/// The response from `/session-token`. +#[derive(Debug, Deserialize, PartialEq)] +#[serde(rename_all = "camelCase")] +pub struct Response { + /// Whether the database was deleted or not. + pub did_delete_database: bool, + /// Whether the request had any errors during the execution. + pub error: bool, + /// Whether the request was successful. + pub success: bool, +} + +/// The `/session-token`-specific error kind. +#[derive(thiserror::Error, Debug, PartialEq)] +pub enum Error { + /// Some error occured. We don't really expect any though. + #[error("unknown error: {0}")] + Unknown(String), +} + +#[cfg(test)] +mod tests { + use wiremock::{ + matchers::{self}, + Mock, MockServer, ResponseTemplate, + }; + + use crate::tests::test_client; + + use super::*; + + #[test] + fn response_deserialization() { + let sample_response = serde_json::json!({ + "additionalSessionData": { + "isAdditionalDataPartiallyIncomplete": true + }, + "callData": { + "tid": "8qV1E1vw1AW-518a5c2a-ff75-11ea-8db5-0232fd4aba88", + "path": "/delete-database-if-less-than-10-records", + "date": "Sep 25, 2020 21:23:13 PM", + "epochSecond": 1601068993, + "requestMethod": "DELETE" + }, + "didDeleteDatabase": true, + "error": false, + "success": true + }); + + let response: Response = serde_json::from_value(sample_response).unwrap(); + assert_matches!( + response, + Response { + did_delete_database: true, + error: false, + success: true, + .. + } + ) + } + + #[tokio::test] + async fn mock_success() { + let mock_server = MockServer::start().await; + + let sample_response = serde_json::json!({ + "additionalSessionData": { + "isAdditionalDataPartiallyIncomplete": true + }, + "callData": { + "tid": "8qV1E1vw1AW-518a5c2a-ff75-11ea-8db5-0232fd4aba88", + "path": "/delete-database-if-less-than-10-records", + "date": "Sep 25, 2020 21:23:13 PM", + "epochSecond": 1601068993, + "requestMethod": "DELETE" + }, + "didDeleteDatabase": true, + "error": false, + "success": true + }); + + let expected_response: Response = serde_json::from_value(sample_response.clone()).unwrap(); + + Mock::given(matchers::method("DELETE")) + .and(matchers::path("/delete-database-if-less-than-10-records")) + .and(matchers::body_bytes(vec![b'1'])) + .respond_with(ResponseTemplate::new(200).set_body_json(&sample_response)) + .mount(&mock_server) + .await; + + let client = test_client(mock_server.uri()); + + let actual_response = client.reset().await.unwrap(); + assert_eq!(actual_response, expected_response); + } + + #[tokio::test] + async fn mock_error_unknown() { + let mock_server = MockServer::start().await; + + let sample_response = "Some error text"; + + Mock::given(matchers::method("DELETE")) + .and(matchers::path("/delete-database-if-less-than-10-records")) + .and(matchers::body_bytes(vec![b'1'])) + .respond_with(ResponseTemplate::new(500).set_body_string(sample_response)) + .mount(&mock_server) + .await; + + let client = test_client(mock_server.uri()); + + let actual_error = client.reset().await.unwrap_err(); + assert_matches!( + actual_error, + crate::Error::Call(Error::Unknown(error_text)) if error_text == sample_response + ); + } +} diff --git a/crates/facetec-api-client/src/response_body_error.rs b/crates/facetec-api-client/src/response_body_error.rs new file mode 100644 index 000000000..daa39ec1c --- /dev/null +++ b/crates/facetec-api-client/src/response_body_error.rs @@ -0,0 +1,47 @@ +//! Response body error. + +use thiserror::Error; + +/// An error while loading or parsing the response body. +#[derive(Error, Debug)] +pub enum ResponseBodyError { + /// Unable to read the response body, probably due to a socket failure of some kind. + #[error("response body reading error: {0}")] + BodyRead(#[source] reqwest::Error), + /// Unable to parse the JSON response. Might be because the response is not in JSON when we + /// expected it to be in JSON, or if the JSON that we got does not match the definition that + /// serde expects on our end. + #[error("JSON response parsing error: {source}")] + Json { + /// The underlying [`serde_json::Error`] error. + #[source] + source: serde_json::Error, + /// The full response body that caused this error, useful for inspection. + body: bytes::Bytes, + }, +} + +/// An interface to allow inspection of the response body errors. +#[async_trait::async_trait] +pub trait Inspector { + /// Invoked when we're reading the raw bytes, before parsing. + async fn inspect_raw(&self, bytes: &[u8]); + + /// Invoked when the response body error occurs. + async fn inspect_error(&self, error: &ResponseBodyError); +} + +/// An inspector that does nothing. +#[derive(Debug, Clone, Copy, Default)] +pub struct NoopInspector; + +#[async_trait::async_trait] +impl Inspector for NoopInspector { + async fn inspect_raw(&self, _bytes: &[u8]) { + // do nothing + } + + async fn inspect_error(&self, _error: &ResponseBodyError) { + // do nothing + } +} diff --git a/crates/facetec-api-client/src/serde_util.rs b/crates/facetec-api-client/src/serde_util.rs new file mode 100644 index 000000000..1fed6f490 --- /dev/null +++ b/crates/facetec-api-client/src/serde_util.rs @@ -0,0 +1,14 @@ +//! Utilities for serde. + +use serde::Deserialize; + +/// Internal type to parse values on the contents. +/// Useful for extracting errors from 200-ok responses. +#[derive(Debug, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum Either { + /// Left variant. + Left(A), + /// Right variant. + Right(B), +} diff --git a/crates/facetec-api-client/src/session_token.rs b/crates/facetec-api-client/src/session_token.rs index bb535dc8c..ab5074904 100644 --- a/crates/facetec-api-client/src/session_token.rs +++ b/crates/facetec-api-client/src/session_token.rs @@ -3,17 +3,20 @@ use reqwest::StatusCode; use serde::Deserialize; -use crate::{CommonResponse, Error}; +use crate::CommonResponse; use super::Client; -impl Client { +impl Client +where + RBEI: crate::response_body_error::Inspector, +{ /// Perform the `/session-token` call to the server. - pub async fn session_token(&self) -> Result> { + pub async fn session_token(&self) -> Result> { let res = self.build_get("/session-token").send().await?; match res.status() { - StatusCode::OK => Ok(res.json().await?), - _ => Err(Error::Call(SessionTokenError::Unknown(res.text().await?))), + StatusCode::OK => Ok(self.parse_json(res).await?), + _ => Err(crate::Error::Call(Error::Unknown(res.text().await?))), } } } @@ -21,7 +24,7 @@ impl Client { /// The response from `/session-token`. #[derive(Debug, Deserialize, PartialEq)] #[serde(rename_all = "camelCase")] -pub struct SessionTokenResponse { +pub struct Response { /// Common response portion. #[serde(flatten)] pub common: CommonResponse, @@ -34,8 +37,8 @@ pub struct SessionTokenResponse { } /// The `/session-token`-specific error kind. -#[derive(Error, Debug, PartialEq)] -pub enum SessionTokenError { +#[derive(thiserror::Error, Debug, PartialEq)] +pub enum Error { /// Some error occured. We don't really expect any though. #[error("unknown error: {0}")] Unknown(String), @@ -48,7 +51,7 @@ mod tests { Mock, MockServer, ResponseTemplate, }; - use crate::{AdditionalSessionData, CallData}; + use crate::{tests::test_client, AdditionalSessionData, CallData}; use super::*; @@ -75,10 +78,10 @@ mod tests { "success": true }); - let response: SessionTokenResponse = serde_json::from_value(sample_response).unwrap(); + let response: Response = serde_json::from_value(sample_response).unwrap(); assert_matches!( response, - SessionTokenResponse { + Response { session_token, error: false, success: true, @@ -122,8 +125,7 @@ mod tests { "success": true }); - let expected_response: SessionTokenResponse = - serde_json::from_value(sample_response.clone()).unwrap(); + let expected_response: Response = serde_json::from_value(sample_response.clone()).unwrap(); Mock::given(matchers::method("GET")) .and(matchers::path("/session-token")) @@ -132,11 +134,7 @@ mod tests { .mount(&mock_server) .await; - let client = Client { - base_url: mock_server.uri(), - reqwest: reqwest::Client::new(), - device_key_identifier: "my device key identifier".into(), - }; + let client = test_client(mock_server.uri()); let actual_response = client.session_token().await.unwrap(); assert_eq!(actual_response, expected_response); @@ -155,16 +153,12 @@ mod tests { .mount(&mock_server) .await; - let client = Client { - base_url: mock_server.uri(), - reqwest: reqwest::Client::new(), - device_key_identifier: "my device key identifier".into(), - }; + let client = test_client(mock_server.uri()); let actual_error = client.session_token().await.unwrap_err(); assert_matches!( actual_error, - Error::Call(SessionTokenError::Unknown(error_text)) if error_text == sample_response + crate::Error::Call(Error::Unknown(error_text)) if error_text == sample_response ); } } diff --git a/crates/facetec-api-client/src/tests.rs b/crates/facetec-api-client/src/tests.rs new file mode 100644 index 000000000..a17b1f41a --- /dev/null +++ b/crates/facetec-api-client/src/tests.rs @@ -0,0 +1,12 @@ +use crate::{response_body_error::NoopInspector, Client}; + +/// Create a standard test client. +pub fn test_client(base_url: String) -> Client { + Client { + base_url, + reqwest: reqwest::Client::new(), + device_key_identifier: "my device key identifier".into(), + injected_ip_address: None, + response_body_error_inspector: crate::response_body_error::NoopInspector, + } +} diff --git a/crates/facetec-api-client/src/types.rs b/crates/facetec-api-client/src/types.rs index 448e2b549..17ab6bdef 100644 --- a/crates/facetec-api-client/src/types.rs +++ b/crates/facetec-api-client/src/types.rs @@ -35,13 +35,13 @@ pub struct AdditionalSessionData { #[serde(rename_all = "camelCase")] pub struct FaceScanSecurityChecks { /// TODO: document - audit_trail_verification_check_succeeded: bool, + pub audit_trail_verification_check_succeeded: bool, /// TODO: document - face_scan_liveness_check_succeeded: bool, + pub face_scan_liveness_check_succeeded: bool, /// TODO: document - replay_check_succeeded: bool, + pub replay_check_succeeded: bool, /// TODO: document - session_token_check_succeeded: bool, + pub session_token_check_succeeded: bool, } impl FaceScanSecurityChecks { @@ -101,9 +101,6 @@ pub struct FaceScanResponse { pub face_scan_security_checks: FaceScanSecurityChecks, /// Something to do with the retry screen of the FaceTec Device SDK. /// TODO: find more info on this parameter. - pub face_tec_retry_screen: i64, - /// Something to do with the retry screen of the FaceTec Device SDK. - /// TODO: find more info on this parameter. pub retry_screen_enum_int: i64, /// The age group enum id that the input FaceScan was classified to. pub age_estimate_group_enum_int: i64, diff --git a/crates/robonode-server/Cargo.toml b/crates/robonode-server/Cargo.toml index 8b9177268..b4d6695eb 100644 --- a/crates/robonode-server/Cargo.toml +++ b/crates/robonode-server/Cargo.toml @@ -19,4 +19,11 @@ sc-tracing = "3" serde = { version = "1", features = ["derive"] } tokio = { version = "1", features = ["full"] } tracing = "0.1" +uuid = { version = "0.8", features = ["v4"] } warp = "0.3" + +[dev-dependencies] +tracing-test = "0.1" + +[features] +logic-integration-tests = [] diff --git a/crates/robonode-server/src/http/filters.rs b/crates/robonode-server/src/http/filters.rs index aa820e909..c67eb86d6 100644 --- a/crates/robonode-server/src/http/filters.rs +++ b/crates/robonode-server/src/http/filters.rs @@ -6,7 +6,7 @@ use warp::Filter; use crate::{ http::handlers, - logic::{AuthenticateRequest, EnrollRequest, Logic, Signer, Verifier}, + logic::{op_authenticate, op_enroll, Logic, Signer, Verifier}, }; /// Pass the [`Arc`] to the handler. @@ -54,7 +54,7 @@ where warp::path!("enroll") .and(warp::post()) .and(with_arc(logic)) - .and(json_body::()) + .and(json_body::()) .and_then(handlers::enroll) } @@ -69,7 +69,7 @@ where warp::path!("authenticate") .and(warp::post()) .and(with_arc(logic)) - .and(json_body::()) + .and(json_body::()) .and_then(handlers::authenticate) } diff --git a/crates/robonode-server/src/http/handlers.rs b/crates/robonode-server/src/http/handlers.rs index f1c28b915..03d9b3335 100644 --- a/crates/robonode-server/src/http/handlers.rs +++ b/crates/robonode-server/src/http/handlers.rs @@ -1,16 +1,18 @@ //! Handlers, the HTTP transport coupling for the internal logic. use std::{convert::TryFrom, sync::Arc}; -use warp::Reply; - use warp::hyper::StatusCode; +use warp::Reply; -use crate::logic::{self, AuthenticateRequest, EnrollRequest, Logic, Signer, Verifier}; +use crate::logic::{ + op_authenticate, op_enroll, op_get_facetec_device_sdk_params, op_get_facetec_session_token, + Logic, Signer, Verifier, +}; /// Enroll operation HTTP transport coupling. pub async fn enroll( logic: Arc>, - input: EnrollRequest, + input: op_enroll::Request, ) -> Result where S: Signer> + Send + 'static, @@ -25,7 +27,7 @@ where /// Authenticate operation HTTP transport coupling. pub async fn authenticate( logic: Arc>, - input: AuthenticateRequest, + input: op_authenticate::Request, ) -> Result where S: Signer> + Send + 'static, @@ -71,7 +73,7 @@ where } } -impl warp::reject::Reject for logic::EnrollError {} -impl warp::reject::Reject for logic::AuthenticateError {} -impl warp::reject::Reject for logic::GetFacetecSessionTokenError {} -impl warp::reject::Reject for logic::GetFacetecDeviceSdkParamsError {} +impl warp::reject::Reject for op_enroll::Error {} +impl warp::reject::Reject for op_authenticate::Error {} +impl warp::reject::Reject for op_get_facetec_device_sdk_params::Error {} +impl warp::reject::Reject for op_get_facetec_session_token::Error {} diff --git a/crates/robonode-server/src/http.rs b/crates/robonode-server/src/http/mod.rs similarity index 100% rename from crates/robonode-server/src/http.rs rename to crates/robonode-server/src/http/mod.rs diff --git a/crates/robonode-server/src/lib.rs b/crates/robonode-server/src/lib.rs index 7c592e103..ca59f361b 100644 --- a/crates/robonode-server/src/lib.rs +++ b/crates/robonode-server/src/lib.rs @@ -13,21 +13,25 @@ use tokio::sync::Mutex; use warp::Filter; mod http; +mod logging_inspector; mod logic; mod sequence; +pub use logging_inspector::LoggingInspector; pub use logic::FacetecDeviceSdkParams; /// Initialize the [`warp::Filter`] implementing the HTTP transport for /// the robonode. pub fn init( - facetec_api_client: facetec_api_client::Client, + execution_id: String, + facetec_api_client: facetec_api_client::Client, facetec_device_sdk_params: FacetecDeviceSdkParams, robonode_keypair: robonode_crypto::Keypair, ) -> impl Filter + Clone { let logic = logic::Logic { locked: Mutex::new(logic::Locked { sequence: sequence::Sequence::new(0), + execution_id, facetec: facetec_api_client, signer: robonode_keypair, public_key_type: PhantomData::, diff --git a/crates/robonode-server/src/logging_inspector.rs b/crates/robonode-server/src/logging_inspector.rs new file mode 100644 index 000000000..75d588570 --- /dev/null +++ b/crates/robonode-server/src/logging_inspector.rs @@ -0,0 +1,30 @@ +//! An [`ft::response_body_error::Inspector`] that will log errors. + +use facetec_api_client as ft; +use tracing::{error, trace}; + +/// An inspector that will log the errors. +#[derive(Debug, Clone, Copy, Default)] +pub struct LoggingInspector; + +#[async_trait::async_trait] +impl ft::response_body_error::Inspector for LoggingInspector { + async fn inspect_raw(&self, bytes: &[u8]) { + let body = String::from_utf8_lossy(bytes); + trace!(message = "FaceTec API response obtained", %body); + } + + async fn inspect_error(&self, err: &ft::ResponseBodyError) { + match err { + ft::ResponseBodyError::Json { source, body } => error!( + message = "FaceTec API failed to parse JSON response", + error = %source, + body = ?body, + ), + err => error!( + message = "FaceTec API failed to parse response body", + error = %err, + ), + } + } +} diff --git a/crates/robonode-server/src/logic.rs b/crates/robonode-server/src/logic.rs deleted file mode 100644 index 802eee517..000000000 --- a/crates/robonode-server/src/logic.rs +++ /dev/null @@ -1,462 +0,0 @@ -//! Core logic of the system. - -use std::{convert::TryFrom, marker::PhantomData}; - -use facetec_api_client::{ - Client as FacetecClient, DBEnrollError, DBEnrollRequest, DBSearchError, DBSearchRequest, - Enrollment3DError, Enrollment3DErrorBadRequest, Enrollment3DRequest, Error as FacetecError, - SessionTokenError, -}; -use primitives_auth_ticket::{AuthTicket, OpaqueAuthTicket}; -use primitives_liveness_data::{LivenessData, OpaqueLivenessData}; -use tokio::sync::Mutex; - -use crate::sequence::Sequence; -use serde::{Deserialize, Serialize}; - -/// Signer provides signatures for the data. -#[async_trait::async_trait] -pub trait Signer { - /// Signature error. - /// Error may originate from communicating with HSM, or from a thread pool failure, etc. - type Error; - - /// Sign the provided data and return the signature, or an error if the siging fails. - async fn sign<'a, D>(&self, data: D) -> Result - where - D: AsRef<[u8]> + Send + 'a; -} - -/// Verifier provides the verification of the data accompanied with the -/// signature or proof data. -#[async_trait::async_trait] -pub trait Verifier { - /// Verification error. - /// Error may originate from communicating with HSM, or from a thread pool failure, etc. - type Error; - - /// Verify that provided data is indeed correctly signed with the provided - /// signature. - async fn verify<'a, D>(&self, data: D, signature: S) -> Result - where - D: AsRef<[u8]> + Send + 'a; -} - -/// The FaceTec Device SDK params. -#[derive(Debug)] -pub struct FacetecDeviceSdkParams { - /// The public FaceMap encription key. - pub public_face_map_encryption_key: String, - /// The device key identifier. - pub device_key_identifier: String, -} - -/// The inner state, to be hidden behind the mutex to ensure we don't have -/// access to it unless we lock the mutex. -pub struct Locked { - /// The sequence number. - pub sequence: Sequence, - /// The client for the FaceTec Server API. - pub facetec: FacetecClient, - /// The utility for signing the responses. - pub signer: S, - /// Public key type to use under the hood. - pub public_key_type: PhantomData, -} - -/// The overall generic logic. -pub struct Logic { - /// The mutex over the locked portions of the logic. - /// This way we're ensureing the operations can only be conducted under - /// the lock. - pub locked: Mutex>, - /// The FaceTec Device SDK params to expose. - pub facetec_device_sdk_params: FacetecDeviceSdkParams, -} - -/// The request for the enroll operation. -#[derive(Debug, Deserialize)] -pub struct EnrollRequest { - /// The public key of the validator. - public_key: Vec, - /// The liveness data that the validator owner provided. - liveness_data: OpaqueLivenessData, -} - -/// The errors on the enroll operation. -#[derive(Debug)] -pub enum EnrollError { - /// The provided public key failed to load because it was invalid. - InvalidPublicKey, - /// The provided opaque liveness data could not be decoded. - InvalidLivenessData(>::Error), - /// This FaceScan was rejected. - FaceScanRejected, - /// This Public Key was already used. - PublicKeyAlreadyUsed, - /// This person has already enrolled into the system. - /// It can also happen if matching returns false-positive. - PersonAlreadyEnrolled, - /// Internal error at server-level enrollment due to the underlying request - /// error at the API level. - InternalErrorEnrollment(FacetecError), - /// Internal error at server-level enrollment due to unsuccessful response, - /// but for some other reason but the FaceScan being rejected. - /// Rejected FaceScan is explicitly encoded via a different error condition. - InternalErrorEnrollmentUnsuccessful, - /// Internal error at 3D-DB search due to the underlying request - /// error at the API level. - InternalErrorDbSearch(FacetecError), - /// Internal error at 3D-DB search due to unsuccessful response. - InternalErrorDbSearchUnsuccessful, - /// Internal error at 3D-DB enrollment due to the underlying request - /// error at the API level. - InternalErrorDbEnroll(FacetecError), - /// Internal error at 3D-DB enrollment due to unsuccessful response. - InternalErrorDbEnrollUnsuccessful, -} - -/// This is the error message that FaceTec server returns when it -/// encounters an `externalDatabaseRefID` that is already in use. -/// For the lack of a better option, we have to compare the error messages, -/// which is not a good idea, and there should've been a better way. -const EXTERNAL_DATABASE_REF_ID_ALREADY_IN_USE_ERROR_MESSAGE: &str = - "An enrollment already exists for this externalDatabaseRefID."; - -/// The group name at 3D DB. -const DB_GROUP_NAME: &str = ""; -/// The match level to use throughout the code. -const MATCH_LEVEL: i64 = 10; - -impl Logic -where - S: Signer> + Send + 'static, - PK: Send + for<'a> TryFrom<&'a [u8]> + AsRef<[u8]>, -{ - /// An enroll invocation handler. - pub async fn enroll(&self, req: EnrollRequest) -> Result<(), EnrollError> { - let public_key = - PK::try_from(&req.public_key).map_err(|_| EnrollError::InvalidPublicKey)?; - - let liveness_data = - LivenessData::try_from(&req.liveness_data).map_err(EnrollError::InvalidLivenessData)?; - - let public_key_hex = hex::encode(public_key); - - let unlocked = self.locked.lock().await; - let enroll_res = unlocked - .facetec - .enrollment_3d(Enrollment3DRequest { - external_database_ref_id: &public_key_hex, - face_scan: &liveness_data.face_scan, - audit_trail_image: &liveness_data.audit_trail_image, - low_quality_audit_trail_image: &liveness_data.low_quality_audit_trail_image, - }) - .await - .map_err(|err| match err { - FacetecError::Call(Enrollment3DError::BadRequest( - Enrollment3DErrorBadRequest { error_message, .. }, - )) if error_message == EXTERNAL_DATABASE_REF_ID_ALREADY_IN_USE_ERROR_MESSAGE => { - EnrollError::PublicKeyAlreadyUsed - } - err => EnrollError::InternalErrorEnrollment(err), - })?; - - if !enroll_res.success { - if !enroll_res - .face_scan - .face_scan_security_checks - .all_checks_succeeded() - { - return Err(EnrollError::FaceScanRejected); - } - return Err(EnrollError::InternalErrorEnrollmentUnsuccessful); - } - - let search_res = unlocked - .facetec - .db_search(DBSearchRequest { - external_database_ref_id: &public_key_hex, - group_name: DB_GROUP_NAME, - min_match_level: MATCH_LEVEL, - }) - .await - .map_err(EnrollError::InternalErrorDbSearch)?; - - if !enroll_res.success { - return Err(EnrollError::InternalErrorDbSearchUnsuccessful); - } - - // If the results set is non-empty - this means that this person has - // already enrolled with the system. It might also be a false-positive. - if !search_res.results.is_empty() { - return Err(EnrollError::PersonAlreadyEnrolled); - } - - let enroll_res = unlocked - .facetec - .db_enroll(DBEnrollRequest { - external_database_ref_id: &public_key_hex, - group_name: "", - }) - .await - .map_err(EnrollError::InternalErrorDbEnroll)?; - - if !enroll_res.success { - return Err(EnrollError::InternalErrorDbEnrollUnsuccessful); - } - - Ok(()) - } -} - -/// The request of the authenticate operation. -#[derive(Debug, Deserialize)] -pub struct AuthenticateRequest { - /// The liveness data that the validator owner provided. - liveness_data: OpaqueLivenessData, - /// The signature of the liveness data with the private key of the node. - /// Proves the posession of the private key by the liveness data bearer. - liveness_data_signature: Vec, -} - -/// The response of the authenticate operation. -#[derive(Debug, Serialize)] -pub struct AuthenticateResponse { - /// An opaque auth ticket generated for this authentication attempt. - /// Contains a public key that matched with the provided FaceScan and a nonce to prevent replay - /// attacks. - auth_ticket: OpaqueAuthTicket, - /// The signature of the auth ticket, signed with the robonode's private key. - /// Can be used together with the auth ticket above to prove that this - /// auth ticket was vetted by the robonode and verified to be associated - /// with a FaceScan. - auth_ticket_signature: Vec, -} - -/// Errors for the authenticate operation. -#[derive(Debug)] -pub enum AuthenticateError { - /// The provided opaque liveness data could not be decoded. - InvalidLivenessData(>::Error), - /// This FaceScan was rejected. - FaceScanRejected, - /// This person was not found. - /// Unually this means they need to enroll, but it can also happen if - /// matching returns false-negative. - PersonNotFound, - /// The liveness data signature validation failed. - /// This means that the user might've provided a signature using different - /// keypair from what was used for the original enrollment. - SignatureInvalid, - /// Internal error at server-level enrollment due to the underlying request - /// error at the API level. - InternalErrorEnrollment(FacetecError), - /// Internal error at server-level enrollment due to unsuccessful response, - /// but for some other reason but the FaceScan being rejected. - /// Rejected FaceScan is explicitly encoded via a different error condition. - InternalErrorEnrollmentUnsuccessful, - /// Internal error at 3D-DB search due to the underlying request - /// error at the API level. - InternalErrorDbSearch(FacetecError), - /// Internal error at 3D-DB search due to unsuccessful response. - InternalErrorDbSearchUnsuccessful, - /// Internal error at 3D-DB search due to match-level mismatch in - /// the search results. - InternalErrorDbSearchMatchLevelMismatch, - /// Internal error at converting public key hex representation to bytes. - InternalErrorInvalidPublicKeyHex, - /// Internal error at public key loading due to invalid public key. - InternalErrorInvalidPublicKey, - /// Internal error at signature verification. - InternalErrorSignatureVerificationFailed, - /// Internal error when signing auth ticket. - InternalErrorAuthTicketSigningFailed, -} - -impl Logic -where - S: Signer> + Send + 'static, - PK: Send + Sync + for<'a> TryFrom<&'a [u8]> + Verifier> + Into>, -{ - /// An authenticate invocation handler. - pub async fn authenticate( - &self, - req: AuthenticateRequest, - ) -> Result { - let liveness_data = LivenessData::try_from(&req.liveness_data) - .map_err(AuthenticateError::InvalidLivenessData)?; - - let mut unlocked = self.locked.lock().await; - - // Bump the sequence counter. - unlocked.sequence.inc(); - let sequence_value = unlocked.sequence.get(); - - // Prepare the ID to be used for this temporary FaceScan. - let tmp_external_database_ref_id = format!("tmp-{}", sequence_value); - - let enroll_res = unlocked - .facetec - .enrollment_3d(Enrollment3DRequest { - external_database_ref_id: &tmp_external_database_ref_id, - face_scan: &liveness_data.face_scan, - audit_trail_image: &liveness_data.audit_trail_image, - low_quality_audit_trail_image: &liveness_data.low_quality_audit_trail_image, - }) - .await - .map_err(AuthenticateError::InternalErrorEnrollment)?; - - if !enroll_res.success { - if !enroll_res - .face_scan - .face_scan_security_checks - .all_checks_succeeded() - { - return Err(AuthenticateError::FaceScanRejected); - } - return Err(AuthenticateError::InternalErrorEnrollmentUnsuccessful); - } - - let search_res = unlocked - .facetec - .db_search(DBSearchRequest { - external_database_ref_id: &tmp_external_database_ref_id, - group_name: DB_GROUP_NAME, - min_match_level: MATCH_LEVEL, - }) - .await - .map_err(AuthenticateError::InternalErrorDbSearch)?; - - if !enroll_res.success { - return Err(AuthenticateError::InternalErrorDbSearchUnsuccessful); - } - - // If the results set is empty - this means that this person was not - // found in the system. - let found = search_res - .results - .first() - .ok_or(AuthenticateError::PersonNotFound)?; - if found.match_level != MATCH_LEVEL { - return Err(AuthenticateError::InternalErrorDbSearchMatchLevelMismatch); - } - - let public_key_bytes = hex::decode(&found.identifier) - .map_err(|_| AuthenticateError::InternalErrorInvalidPublicKeyHex)?; - let public_key = PK::try_from(&public_key_bytes) - .map_err(|_| AuthenticateError::InternalErrorInvalidPublicKey)?; - - let signature_valid = public_key - .verify(&req.liveness_data, req.liveness_data_signature) - .await - .map_err(|_| AuthenticateError::InternalErrorSignatureVerificationFailed)?; - - if !signature_valid { - return Err(AuthenticateError::SignatureInvalid); - } - - // Prepare an authentication nonce from the sequence number. - // TODO: we don't want to expose our internal sequence number, so this value should - // be hashed, or obfuscated by other means. - let authentication_nonce = Vec::from(&sequence_value.to_ne_bytes()[..]); - - // Prepare the raw auth ticket. - let auth_ticket = AuthTicket { - public_key: public_key.into(), - authentication_nonce, - }; - - // Prepare an opaque auth ticket, get ready for signing. - let opaque_auth_ticket = (&auth_ticket).into(); - - // Sign the auth ticket with our private key, so that later on it's possible to validate - // this ticket was issues by us. - let auth_ticket_signature = unlocked - .signer - .sign(&opaque_auth_ticket) - .await - .map_err(|_| AuthenticateError::InternalErrorAuthTicketSigningFailed)?; - - Ok(AuthenticateResponse { - auth_ticket: opaque_auth_ticket, - auth_ticket_signature, - }) - } -} - -/// The response for the get facetec session token operation. -#[derive(Debug, Serialize)] -pub struct GetFacetecSessionTokenResponse { - /// The session token returned by the FaceTec Server. - session_token: String, -} - -/// Errors for the get facetec session token operation. -#[derive(Debug)] -pub enum GetFacetecSessionTokenError { - /// Internal error at session token retrieval due to the underlying request - /// error at the API level. - InternalErrorSessionToken(FacetecError), - /// Internal error at session token retrieval due to unsuccessful response. - InternalErrorSessionTokenUnsuccessful, -} - -impl Logic -where - S: Signer> + Send + 'static, - PK: Send + for<'a> TryFrom<&'a [u8]>, -{ - /// Get a FaceTec Session Token. - pub async fn get_facetec_session_token( - &self, - ) -> Result { - let unlocked = self.locked.lock().await; - - let res = unlocked - .facetec - .session_token() - .await - .map_err(GetFacetecSessionTokenError::InternalErrorSessionToken)?; - - if !res.success { - return Err(GetFacetecSessionTokenError::InternalErrorSessionTokenUnsuccessful); - } - - Ok(GetFacetecSessionTokenResponse { - session_token: res.session_token, - }) - } -} - -/// The response for the get facetec device sdk params operation. -#[derive(Debug, Serialize)] -pub struct GetFacetecDeviceSdkParamsResponse { - /// The public FaceMap encription key. - pub public_face_map_encryption_key: String, - /// The device key identifier. - pub device_key_identifier: String, -} - -/// Errors for the get facetec device sdk params operation. -#[derive(Debug)] -pub enum GetFacetecDeviceSdkParamsError {} - -impl Logic -where - S: Signer> + Send + 'static, - PK: Send + for<'a> TryFrom<&'a [u8]>, -{ - /// Get the FaceTec Device SDK params . - pub async fn get_facetec_device_sdk_params( - &self, - ) -> Result { - Ok(GetFacetecDeviceSdkParamsResponse { - device_key_identifier: self.facetec_device_sdk_params.device_key_identifier.clone(), - public_face_map_encryption_key: self - .facetec_device_sdk_params - .public_face_map_encryption_key - .clone(), - }) - } -} diff --git a/crates/robonode-server/src/logic/common.rs b/crates/robonode-server/src/logic/common.rs new file mode 100644 index 000000000..963e6c770 --- /dev/null +++ b/crates/robonode-server/src/logic/common.rs @@ -0,0 +1,13 @@ +//! Common logic parameters. + +/// This is the error message that FaceTec server returns when it +/// encounters an `externalDatabaseRefID` that is already in use. +/// For the lack of a better option, we have to compare the error messages, +/// which is not a good idea, and there should've been a better way. +pub const EXTERNAL_DATABASE_REF_ID_ALREADY_IN_USE_ERROR_MESSAGE: &str = + "An enrollment already exists for this externalDatabaseRefID."; + +/// The group name at 3D DB. +pub const DB_GROUP_NAME: &str = "humanode"; +/// The match level to use throughout the code. +pub const MATCH_LEVEL: i64 = 10; diff --git a/crates/robonode-server/src/logic/facetec_utils.rs b/crates/robonode-server/src/logic/facetec_utils.rs new file mode 100644 index 000000000..b9bacbef0 --- /dev/null +++ b/crates/robonode-server/src/logic/facetec_utils.rs @@ -0,0 +1,31 @@ +//! FaceTec utilities. + +use facetec_api_client as ft; + +/// An enum with all of the meaningful outcomes from the db seatch result. +pub enum DbSearchResult { + /// A usual response. + Response(ft::db_search::Response), + /// A special case - an error indicating that tthe group we searched at doesn't exist. + /// We can treat it as a valid response with no results for our use case. + NoGroupError, + /// Some other error occured. + OtherError(ft::Error), +} + +/// An adapter of the db search results to better fit our logic. +pub fn db_search_result_adapter( + search_res: Result>, +) -> DbSearchResult { + match search_res { + Ok(res) => DbSearchResult::Response(res), + Err(ft::Error::Call(ft::db_search::Error::BadRequest(err))) + if err + .error_message + .starts_with("Tried to search a groupName when that groupName does not exist.") => + { + DbSearchResult::NoGroupError + } + Err(err) => DbSearchResult::OtherError(err), + } +} diff --git a/crates/robonode-server/src/logic/mod.rs b/crates/robonode-server/src/logic/mod.rs new file mode 100644 index 000000000..c909c3f41 --- /dev/null +++ b/crates/robonode-server/src/logic/mod.rs @@ -0,0 +1,55 @@ +//! Core logic of the system. + +use std::marker::PhantomData; + +use facetec_api_client as ft; +use tokio::sync::Mutex; + +use crate::sequence::Sequence; + +mod common; +mod facetec_utils; +pub mod op_authenticate; +pub mod op_enroll; +pub mod op_get_facetec_device_sdk_params; +pub mod op_get_facetec_session_token; +#[cfg(test)] +mod tests; +mod traits; + +pub use traits::*; + +/// The overall generic logic. +pub struct Logic { + /// The mutex over the locked portions of the logic. + /// This way we're ensuring the operations can only be conducted under + /// the lock. + pub locked: Mutex>, + /// The FaceTec Device SDK params to expose. + pub facetec_device_sdk_params: FacetecDeviceSdkParams, +} + +/// The inner state, to be hidden behind the mutex to ensure we don't have +/// access to it unless we lock the mutex. +pub struct Locked { + /// The sequence number. + pub sequence: Sequence, + /// An execution ID, to be used together with sequence to guarantee unqiueness of the temporary + /// enrollment external database IDs. + pub execution_id: String, + /// The client for the FaceTec Server API. + pub facetec: ft::Client, + /// The utility for signing the responses. + pub signer: S, + /// Public key type to use under the hood. + pub public_key_type: PhantomData, +} + +/// The FaceTec Device SDK params. +#[derive(Debug)] +pub struct FacetecDeviceSdkParams { + /// The public FaceMap encription key. + pub public_face_map_encryption_key: String, + /// The device key identifier. + pub device_key_identifier: String, +} diff --git a/crates/robonode-server/src/logic/op_authenticate.rs b/crates/robonode-server/src/logic/op_authenticate.rs new file mode 100644 index 000000000..6911259a3 --- /dev/null +++ b/crates/robonode-server/src/logic/op_authenticate.rs @@ -0,0 +1,201 @@ +//! Authenticate operation. + +use std::convert::TryFrom; + +use facetec_api_client as ft; +use primitives_auth_ticket::{AuthTicket, OpaqueAuthTicket}; +use primitives_liveness_data::{LivenessData, OpaqueLivenessData}; +use tracing::{error, trace}; + +use serde::{Deserialize, Serialize}; + +use crate::logic::facetec_utils::{db_search_result_adapter, DbSearchResult}; + +use super::{common::*, Logic, Signer, Verifier}; + +/// The request of the authenticate operation. +#[derive(Debug, Deserialize)] +pub struct Request { + /// The liveness data that the validator owner provided. + pub liveness_data: OpaqueLivenessData, + /// The signature of the liveness data with the private key of the node. + /// Proves the posession of the private key by the liveness data bearer. + pub liveness_data_signature: Vec, +} + +/// The response of the authenticate operation. +#[derive(Debug, Serialize)] +pub struct Response { + /// An opaque auth ticket generated for this authentication attempt. + /// Contains a public key that matched with the provided FaceScan and a nonce to prevent replay + /// attacks. + pub auth_ticket: OpaqueAuthTicket, + /// The signature of the auth ticket, signed with the robonode's private key. + /// Can be used together with the auth ticket above to prove that this + /// auth ticket was vetted by the robonode and verified to be associated + /// with a FaceScan. + pub auth_ticket_signature: Vec, +} + +/// Errors for the authenticate operation. +#[derive(Debug)] +pub enum Error { + /// The provided opaque liveness data could not be decoded. + InvalidLivenessData(>::Error), + /// This FaceScan was rejected. + FaceScanRejected, + /// This person was not found. + /// Unually this means they need to enroll, but it can also happen if + /// matching returns false-negative. + PersonNotFound, + /// The liveness data signature validation failed. + /// This means that the user might've provided a signature using different + /// keypair from what was used for the original enrollment. + SignatureInvalid, + /// Internal error at server-level enrollment due to the underlying request + /// error at the API level. + InternalErrorEnrollment(ft::Error), + /// Internal error at server-level enrollment due to unsuccessful response, + /// but for some other reason but the FaceScan being rejected. + /// Rejected FaceScan is explicitly encoded via a different error condition. + InternalErrorEnrollmentUnsuccessful, + /// Internal error at 3D-DB search due to the underlying request + /// error at the API level. + InternalErrorDbSearch(ft::Error), + /// Internal error at 3D-DB search due to unsuccessful response. + InternalErrorDbSearchUnsuccessful, + /// Internal error at 3D-DB search due to match-level mismatch in + /// the search results. + InternalErrorDbSearchMatchLevelMismatch, + /// Internal error at converting public key hex representation to bytes. + InternalErrorInvalidPublicKeyHex, + /// Internal error at public key loading due to invalid public key. + InternalErrorInvalidPublicKey, + /// Internal error at signature verification. + InternalErrorSignatureVerificationFailed, + /// Internal error when signing auth ticket. + InternalErrorAuthTicketSigningFailed, +} + +impl Logic +where + S: Signer> + Send + 'static, + PK: Send + Sync + for<'a> TryFrom<&'a [u8]> + Verifier> + Into>, +{ + /// An authenticate invocation handler. + pub async fn authenticate(&self, req: Request) -> Result { + let liveness_data = + LivenessData::try_from(&req.liveness_data).map_err(Error::InvalidLivenessData)?; + + let mut unlocked = self.locked.lock().await; + + // Bump the sequence counter. + unlocked.sequence.inc(); + let sequence_value = unlocked.sequence.get(); + + // Prepare the ID to be used for this temporary FaceScan. + let tmp_external_database_ref_id = + format!("tmp-{}-{}", &unlocked.execution_id, sequence_value); + + let enroll_res = unlocked + .facetec + .enrollment_3d(ft::enrollment3d::Request { + external_database_ref_id: &tmp_external_database_ref_id, + face_scan: &liveness_data.face_scan, + audit_trail_image: &liveness_data.audit_trail_image, + low_quality_audit_trail_image: &liveness_data.low_quality_audit_trail_image, + }) + .await + .map_err(Error::InternalErrorEnrollment)?; + + trace!(message = "Got FaceTec enroll results", ?enroll_res); + + if !enroll_res.success { + error!( + message = + "Unsuccessful enroll response from FaceTec server during robonode authenticate", + ?enroll_res + ); + if let Some(face_scan) = enroll_res.face_scan { + if !face_scan.face_scan_security_checks.all_checks_succeeded() { + return Err(Error::FaceScanRejected); + } + } + return Err(Error::InternalErrorEnrollmentUnsuccessful); + } + + drop(enroll_res); + + let search_result = unlocked + .facetec + .db_search(ft::db_search::Request { + external_database_ref_id: &tmp_external_database_ref_id, + group_name: DB_GROUP_NAME, + min_match_level: MATCH_LEVEL, + }) + .await; + + let results = match db_search_result_adapter(search_result) { + DbSearchResult::OtherError(err) => return Err(Error::InternalErrorDbSearch(err)), + DbSearchResult::NoGroupError => { + trace!(message = "Got no-group error instead of FaceTec 3D-DB search results, assuming no results"); + vec![] + } + DbSearchResult::Response(search_res) => { + trace!(message = "Got FaceTec 3D-DB search results", ?search_res); + if !search_res.success { + return Err(Error::InternalErrorDbSearchUnsuccessful); + } + search_res.results + } + }; + + // If the results set is empty - this means that this person was not + // found in the system. + let found = results.first().ok_or(Error::PersonNotFound)?; + if found.match_level != MATCH_LEVEL { + return Err(Error::InternalErrorDbSearchMatchLevelMismatch); + } + + let public_key_bytes = + hex::decode(&found.identifier).map_err(|_| Error::InternalErrorInvalidPublicKeyHex)?; + let public_key = + PK::try_from(&public_key_bytes).map_err(|_| Error::InternalErrorInvalidPublicKey)?; + + let signature_valid = public_key + .verify(&req.liveness_data, req.liveness_data_signature) + .await + .map_err(|_| Error::InternalErrorSignatureVerificationFailed)?; + + if !signature_valid { + return Err(Error::SignatureInvalid); + } + + // Prepare an authentication nonce from the sequence number. + // TODO: we don't want to expose our internal sequence number, so this value should + // be hashed, or obfuscated by other means. + let authentication_nonce = Vec::from(&sequence_value.to_ne_bytes()[..]); + + // Prepare the raw auth ticket. + let auth_ticket = AuthTicket { + public_key: public_key.into(), + authentication_nonce, + }; + + // Prepare an opaque auth ticket, get ready for signing. + let opaque_auth_ticket = (&auth_ticket).into(); + + // Sign the auth ticket with our private key, so that later on it's possible to validate + // this ticket was issues by us. + let auth_ticket_signature = unlocked + .signer + .sign(&opaque_auth_ticket) + .await + .map_err(|_| Error::InternalErrorAuthTicketSigningFailed)?; + + Ok(Response { + auth_ticket: opaque_auth_ticket, + auth_ticket_signature, + }) + } +} diff --git a/crates/robonode-server/src/logic/op_enroll.rs b/crates/robonode-server/src/logic/op_enroll.rs new file mode 100644 index 000000000..ca38a327e --- /dev/null +++ b/crates/robonode-server/src/logic/op_enroll.rs @@ -0,0 +1,152 @@ +//! Enroll operation. + +use std::convert::TryFrom; + +use facetec_api_client as ft; +use primitives_liveness_data::{LivenessData, OpaqueLivenessData}; +use serde::Deserialize; +use tracing::{error, trace}; + +use crate::logic::facetec_utils::{db_search_result_adapter, DbSearchResult}; + +use super::{common::*, Logic, Signer}; + +/// The request for the enroll operation. +#[derive(Debug, Deserialize)] +pub struct Request { + /// The public key of the validator. + pub public_key: Vec, + /// The liveness data that the validator owner provided. + pub liveness_data: OpaqueLivenessData, +} + +/// The errors on the enroll operation. +#[derive(Debug)] +pub enum Error { + /// The provided public key failed to load because it was invalid. + InvalidPublicKey, + /// The provided opaque liveness data could not be decoded. + InvalidLivenessData(>::Error), + /// This FaceScan was rejected. + FaceScanRejected, + /// This Public Key was already used. + PublicKeyAlreadyUsed, + /// This person has already enrolled into the system. + /// It can also happen if matching returns false-positive. + PersonAlreadyEnrolled, + /// Internal error at server-level enrollment due to the underlying request + /// error at the API level. + InternalErrorEnrollment(ft::Error), + /// Internal error at server-level enrollment due to unsuccessful response, + /// but for some other reason but the FaceScan being rejected. + /// Rejected FaceScan is explicitly encoded via a different error condition. + InternalErrorEnrollmentUnsuccessful, + /// Internal error at 3D-DB search due to the underlying request + /// error at the API level. + InternalErrorDbSearch(ft::Error), + /// Internal error at 3D-DB search due to unsuccessful response. + InternalErrorDbSearchUnsuccessful, + /// Internal error at 3D-DB enrollment due to the underlying request + /// error at the API level. + InternalErrorDbEnroll(ft::Error), + /// Internal error at 3D-DB enrollment due to unsuccessful response. + InternalErrorDbEnrollUnsuccessful, +} + +impl Logic +where + S: Signer> + Send + 'static, + PK: Send + for<'a> TryFrom<&'a [u8]> + AsRef<[u8]>, +{ + /// An enroll invocation handler. + pub async fn enroll(&self, req: Request) -> Result<(), Error> { + let public_key = PK::try_from(&req.public_key).map_err(|_| Error::InvalidPublicKey)?; + + let liveness_data = + LivenessData::try_from(&req.liveness_data).map_err(Error::InvalidLivenessData)?; + + let public_key_hex = hex::encode(public_key); + + let unlocked = self.locked.lock().await; + let enroll_res = unlocked + .facetec + .enrollment_3d(ft::enrollment3d::Request { + external_database_ref_id: &public_key_hex, + face_scan: &liveness_data.face_scan, + audit_trail_image: &liveness_data.audit_trail_image, + low_quality_audit_trail_image: &liveness_data.low_quality_audit_trail_image, + }) + .await + .map_err(Error::InternalErrorEnrollment)?; + + trace!(message = "Got FaceTec enroll results", ?enroll_res); + + if !enroll_res.success { + error!( + message = "Unsuccessful enroll response from FaceTec server during robonode enroll", + ?enroll_res + ); + if let Some(error_message) = enroll_res.error_message { + if error_message == EXTERNAL_DATABASE_REF_ID_ALREADY_IN_USE_ERROR_MESSAGE { + return Err(Error::PublicKeyAlreadyUsed); + } + } else if let Some(face_scan) = enroll_res.face_scan { + if !face_scan.face_scan_security_checks.all_checks_succeeded() { + return Err(Error::FaceScanRejected); + } + } + return Err(Error::InternalErrorEnrollmentUnsuccessful); + } + + drop(enroll_res); + + let search_result = unlocked + .facetec + .db_search(ft::db_search::Request { + external_database_ref_id: &public_key_hex, + group_name: DB_GROUP_NAME, + min_match_level: MATCH_LEVEL, + }) + .await; + + let results = match db_search_result_adapter(search_result) { + DbSearchResult::OtherError(err) => return Err(Error::InternalErrorDbSearch(err)), + DbSearchResult::NoGroupError => { + trace!(message = "Got no-group error instead of FaceTec 3D-DB search results, assuming no results"); + vec![] + } + DbSearchResult::Response(search_res) => { + trace!(message = "Got FaceTec 3D-DB search results", ?search_res); + if !search_res.success { + return Err(Error::InternalErrorDbSearchUnsuccessful); + } + search_res.results + } + }; + + // If the results set is non-empty - this means that this person has + // already enrolled with the system. It might also be a false-positive. + if !results.is_empty() { + return Err(Error::PersonAlreadyEnrolled); + } + + let db_enroll_res = unlocked + .facetec + .db_enroll(ft::db_enroll::Request { + external_database_ref_id: &public_key_hex, + group_name: DB_GROUP_NAME, + }) + .await + .map_err(Error::InternalErrorDbEnroll)?; + + trace!(message = "Got FaceTec 3D-DB enroll results", ?db_enroll_res); + + if !db_enroll_res.success { + return Err(Error::InternalErrorDbEnrollUnsuccessful); + } + + drop(db_enroll_res); + + Ok(()) + } +} diff --git a/crates/robonode-server/src/logic/op_get_facetec_device_sdk_params.rs b/crates/robonode-server/src/logic/op_get_facetec_device_sdk_params.rs new file mode 100644 index 000000000..473f2b066 --- /dev/null +++ b/crates/robonode-server/src/logic/op_get_facetec_device_sdk_params.rs @@ -0,0 +1,37 @@ +//! Get Facetec Device Sdk Params operation. + +use std::convert::TryFrom; + +use serde::Serialize; + +use super::{Logic, Signer}; + +/// The response for the get facetec device sdk params operation. +#[derive(Debug, Serialize)] +pub struct Response { + /// The public FaceMap encription key. + pub public_face_map_encryption_key: String, + /// The device key identifier. + pub device_key_identifier: String, +} + +/// Errors for the get facetec device sdk params operation. +#[derive(Debug)] +pub enum Error {} + +impl Logic +where + S: Signer> + Send + 'static, + PK: Send + for<'a> TryFrom<&'a [u8]>, +{ + /// Get the FaceTec Device SDK params. + pub async fn get_facetec_device_sdk_params(&self) -> Result { + Ok(Response { + device_key_identifier: self.facetec_device_sdk_params.device_key_identifier.clone(), + public_face_map_encryption_key: self + .facetec_device_sdk_params + .public_face_map_encryption_key + .clone(), + }) + } +} diff --git a/crates/robonode-server/src/logic/op_get_facetec_session_token.rs b/crates/robonode-server/src/logic/op_get_facetec_session_token.rs new file mode 100644 index 000000000..7441ec093 --- /dev/null +++ b/crates/robonode-server/src/logic/op_get_facetec_session_token.rs @@ -0,0 +1,50 @@ +//! Get Facetec Session Token operation. + +use std::convert::TryFrom; + +use facetec_api_client as ft; +use serde::Serialize; + +use super::{Logic, Signer}; + +/// The response for the get facetec session token operation. +#[derive(Debug, Serialize)] +pub struct Response { + /// The session token returned by the FaceTec Server. + pub session_token: String, +} + +/// Errors for the get facetec session token operation. +#[derive(Debug)] +pub enum Error { + /// Internal error at session token retrieval due to the underlying request + /// error at the API level. + InternalErrorSessionToken(ft::Error), + /// Internal error at session token retrieval due to unsuccessful response. + InternalErrorSessionTokenUnsuccessful, +} + +impl Logic +where + S: Signer> + Send + 'static, + PK: Send + for<'a> TryFrom<&'a [u8]>, +{ + /// Get a FaceTec Session Token. + pub async fn get_facetec_session_token(&self) -> Result { + let unlocked = self.locked.lock().await; + + let res = unlocked + .facetec + .session_token() + .await + .map_err(Error::InternalErrorSessionToken)?; + + if !res.success { + return Err(Error::InternalErrorSessionTokenUnsuccessful); + } + + Ok(Response { + session_token: res.session_token, + }) + } +} diff --git a/crates/robonode-server/src/logic/tests.rs b/crates/robonode-server/src/logic/tests.rs new file mode 100644 index 000000000..d7eb38b1d --- /dev/null +++ b/crates/robonode-server/src/logic/tests.rs @@ -0,0 +1,221 @@ +#![cfg(feature = "logic-integration-tests")] + +use std::marker::PhantomData; + +use facetec_api_client as ft; +use primitives_liveness_data::{LivenessData, OpaqueLivenessData}; +use tokio::sync::{Mutex, MutexGuard}; +use tracing::{info, trace}; + +use crate::{logic::common::DB_GROUP_NAME, sequence::Sequence, ValidatorPublicKeyToDo}; + +use super::{Locked, Logic}; + +struct TestSigner; + +#[async_trait::async_trait] +impl super::Signer> for TestSigner { + type Error = (); + + async fn sign<'a, D>(&self, _data: D) -> Result, Self::Error> + where + D: AsRef<[u8]> + Send + 'a, + { + Ok(b"dummy signature".to_vec()) + } +} + +struct TestParams { + facetec_test_server_url: String, + facetec_device_key_identifier: String, + facetec_injected_ip_address: String, + + enroll_liveness_data: OpaqueLivenessData, + authenticate_liveness_data: OpaqueLivenessData, +} + +impl TestParams { + pub fn from_env() -> Self { + let facetec_test_server_url = std::env::var("FACETEC_TEST_SERVER_URL").unwrap(); + let facetec_device_key_identifier = std::env::var("FACETEC_DEVICE_KEY_IDENTIFIER").unwrap(); + let facetec_injected_ip_address = std::env::var("FACETEC_INJECTED_IP_ADDRESS").unwrap(); + + let read_liveness_data = |prefix: &str| { + let read_env_file = |var: &str| { + let val = std::env::var(format!("{}{}", prefix, var)).unwrap(); + std::fs::read_to_string(val).unwrap() + }; + + let face_scan = read_env_file("FACETEC_FACE_SCAN_PATH"); + let audit_trail_image = read_env_file("FACETEC_AUDIT_TRAIL_IMAGE_PATH"); + let low_quality_audit_trail_image = + read_env_file("FACETEC_LOW_QUALITY_AUDIT_TRAIL_IMAGE_PATH"); + + let liveness_data = LivenessData { + face_scan, + audit_trail_image, + low_quality_audit_trail_image, + }; + + OpaqueLivenessData::from(&liveness_data) + }; + + let enroll_liveness_data = read_liveness_data("ENROLL_"); + let authenticate_liveness_data = read_liveness_data("AUTHENTICATE_"); + + assert_ne!(enroll_liveness_data, authenticate_liveness_data); + + Self { + facetec_test_server_url, + facetec_device_key_identifier, + facetec_injected_ip_address, + enroll_liveness_data, + authenticate_liveness_data, + } + } +} + +static LOCK: Mutex<()> = Mutex::const_new(()); + +const TEST_PUBLIC_KEY: &[u8] = b"dummy validator key"; + +/// Returns a list of all public keys to cleanup from the FaceTec Server 3D DB. +fn public_keys_to_cleanup() -> Vec<&'static [u8]> { + vec![TEST_PUBLIC_KEY, b"a", b"b"] +} + +async fn setup() -> ( + MutexGuard<'static, ()>, + TestParams, + Logic, +) { + let guard = LOCK.lock().await; + + let test_params = TestParams::from_env(); + + let facetec = ft::Client { + reqwest: reqwest::Client::new(), + base_url: test_params.facetec_test_server_url.clone(), + device_key_identifier: test_params.facetec_device_key_identifier.clone(), + injected_ip_address: Some(test_params.facetec_injected_ip_address.clone()), + response_body_error_inspector: crate::LoggingInspector, + }; + + let res = facetec + .reset() + .await + .expect("unable to reset facetec test server"); + + trace!(message = "facetec server reset", ?res); + + for public_key_to_clenaup in public_keys_to_cleanup() { + let public_key_hex = hex::encode(public_key_to_clenaup); + let res = facetec + .db_delete(ft::db_delete::Request { + group_name: DB_GROUP_NAME, + identifier: &public_key_hex, + }) + .await + .expect("unable to clear 3D DB at the facetec test server"); + + trace!(message = "3D DB cleanup at the facetec server", ?res); + } + + let locked = Locked { + sequence: Sequence::new(0), + execution_id: "test".to_owned(), + facetec, + signer: TestSigner, + public_key_type: PhantomData::, + }; + let logic = Logic { + locked: Mutex::new(locked), + facetec_device_sdk_params: crate::FacetecDeviceSdkParams { + device_key_identifier: "device_key_identifier".to_owned(), + public_face_map_encryption_key: "public_face_map_encryption_key".to_owned(), + }, + }; + + (guard, test_params, logic) +} + +#[tokio::test] +#[tracing_test::traced_test] +async fn standalone_enroll() { + let (_guard, test_params, logic) = setup().await; + + logic + .enroll(super::op_enroll::Request { + liveness_data: test_params.enroll_liveness_data, + public_key: TEST_PUBLIC_KEY.to_vec(), + }) + .await + .unwrap(); +} + +#[tokio::test] +#[tracing_test::traced_test] +async fn first_authenticate() { + let (_guard, test_params, logic) = setup().await; + + let err = logic + .authenticate(super::op_authenticate::Request { + liveness_data: test_params.authenticate_liveness_data, + liveness_data_signature: b"qwe".to_vec(), + }) + .await + .unwrap_err(); + + assert!(matches!(err, super::op_authenticate::Error::PersonNotFound)); +} + +#[tokio::test] +#[tracing_test::traced_test] +async fn enroll_authenticate() { + let (_guard, test_params, logic) = setup().await; + + logic + .enroll(super::op_enroll::Request { + liveness_data: test_params.enroll_liveness_data, + public_key: TEST_PUBLIC_KEY.to_vec(), + }) + .await + .unwrap(); + + info!("enroll complete, authenticating now"); + + logic + .authenticate(super::op_authenticate::Request { + liveness_data: test_params.authenticate_liveness_data, + liveness_data_signature: b"qwe".to_vec(), + }) + .await + .unwrap(); +} + +#[tokio::test] +#[tracing_test::traced_test] +async fn double_enroll() { + let (_guard, test_params, logic) = setup().await; + + logic + .enroll(super::op_enroll::Request { + liveness_data: test_params.enroll_liveness_data, + public_key: b"a".to_vec(), + }) + .await + .unwrap(); + + let err = logic + .enroll(super::op_enroll::Request { + liveness_data: test_params.authenticate_liveness_data, + public_key: b"b".to_vec(), + }) + .await + .unwrap_err(); + + assert!(matches!( + err, + super::op_enroll::Error::PersonAlreadyEnrolled + )); +} diff --git a/crates/robonode-server/src/logic/traits.rs b/crates/robonode-server/src/logic/traits.rs new file mode 100644 index 000000000..9c594c451 --- /dev/null +++ b/crates/robonode-server/src/logic/traits.rs @@ -0,0 +1,29 @@ +//! The logic-related traits. + +/// Signer provides signatures for the data. +#[async_trait::async_trait] +pub trait Signer { + /// Signature error. + /// Error may originate from communicating with HSM, or from a thread pool failure, etc. + type Error; + + /// Sign the provided data and return the signature, or an error if the siging fails. + async fn sign<'a, D>(&self, data: D) -> Result + where + D: AsRef<[u8]> + Send + 'a; +} + +/// Verifier provides the verification of the data accompanied with the +/// signature or proof data. +#[async_trait::async_trait] +pub trait Verifier { + /// Verification error. + /// Error may originate from communicating with HSM, or from a thread pool failure, etc. + type Error; + + /// Verify that provided data is indeed correctly signed with the provided + /// signature. + async fn verify<'a, D>(&self, data: D, signature: S) -> Result + where + D: AsRef<[u8]> + Send + 'a; +} diff --git a/crates/robonode-server/src/main.rs b/crates/robonode-server/src/main.rs index d6dd3e5b4..fb783576a 100644 --- a/crates/robonode-server/src/main.rs +++ b/crates/robonode-server/src/main.rs @@ -27,13 +27,18 @@ async fn main() -> Result<(), Box> { base_url: facetec_server_url, reqwest: reqwest::Client::new(), device_key_identifier: facetec_device_key_identifier.clone(), + injected_ip_address: None, + response_body_error_inspector: robonode_server::LoggingInspector, }; let face_tec_device_sdk_params = robonode_server::FacetecDeviceSdkParams { device_key_identifier: facetec_device_key_identifier, public_face_map_encryption_key: facetec_public_face_map_encryption_key, }; + let execution_id = uuid::Uuid::new_v4().to_string(); + let root_filter = robonode_server::init( + execution_id, facetec_api_client, face_tec_device_sdk_params, robonode_keypair,