#![forbid(unsafe_code)]
#![deny(clippy::pedantic)]
#![deny(missing_docs)]
#![deny(clippy::cargo)]
#![allow(clippy::multiple_crate_versions)]
#![deny(clippy::unwrap_used)]
#![deny(clippy::panic)]
#![deny(clippy::perf)]
#![deny(clippy::nursery)]
#![deny(clippy::match_like_matches_macro)]
#![allow(clippy::no_effect_underscore_binding)]
mod apple;
mod google;
pub mod error;
use async_trait::async_trait;
use chrono::{DateTime, Utc};
use error::Result;
use serde::{Deserialize, Serialize};
use yup_oauth2::ServiceAccountKey;
pub use apple::{
fetch_apple_receipt_data, fetch_apple_receipt_data_with_urls, validate_apple_package,
validate_apple_subscription, AppleResponse, AppleUrls,
};
pub use google::{
fetch_google_receipt_data, fetch_google_receipt_data_with_uri, validate_google_package,
validate_google_subscription, GoogleResponse, SkuType,
};
#[derive(Deserialize, Serialize, Clone, Debug)]
pub enum Platform {
AppleAppStore,
GooglePlay,
}
impl Default for Platform {
fn default() -> Self {
Self::AppleAppStore
}
}
#[derive(Default, Deserialize, Serialize, Clone, Debug)]
pub struct UnityPurchaseReceipt {
#[serde(rename = "Store")]
pub store: Platform,
#[serde(rename = "Payload")]
pub payload: String,
#[serde(rename = "TransactionID")]
pub transaction_id: String,
}
impl UnityPurchaseReceipt {
pub fn from(json_str: &str) -> Result<Self> {
Ok(serde_json::from_str(json_str)?)
}
}
#[derive(Default, Deserialize, Serialize, Clone, Debug)]
pub struct PurchaseResponse {
pub valid: bool,
pub product_id: Option<String>,
}
#[async_trait]
pub trait Validator: Send + Sync {
async fn validate(
&self,
now: DateTime<Utc>,
receipt: &UnityPurchaseReceipt,
) -> Result<PurchaseResponse>;
}
#[async_trait]
pub trait ReceiptDataFetcher {
async fn fetch_apple_receipt_data(
&self,
receipt: &UnityPurchaseReceipt,
) -> Result<AppleResponse>;
async fn fetch_google_receipt_data(
&self,
receipt: &UnityPurchaseReceipt,
) -> Result<(GoogleResponse, SkuType)>;
}
pub trait ReceiptValidator: ReceiptDataFetcher + Validator {}
#[derive(Default)]
pub struct UnityPurchaseValidator<'a> {
pub secret: Option<String>,
pub apple_urls: AppleUrls<'a>,
pub service_account_key: Option<ServiceAccountKey>,
}
impl ReceiptValidator for UnityPurchaseValidator<'_> {}
impl UnityPurchaseValidator<'_> {
#[allow(clippy::missing_const_for_fn)]
#[must_use]
pub fn set_apple_secret(self, secret: String) -> Self {
tracing::info!("Setting apple secret");
let mut new = self;
new.secret = Some(secret);
new
}
#[allow(clippy::must_use_candidate)]
pub fn set_google_service_account_key<S: AsRef<[u8]>>(self, secret: S) -> Result<Self> {
let mut new = self;
new.service_account_key = Some(google::get_service_account_key(secret)?);
Ok(new)
}
}
#[async_trait]
impl Validator for UnityPurchaseValidator<'_> {
async fn validate(
&self,
now: DateTime<Utc>,
receipt: &UnityPurchaseReceipt,
) -> Result<PurchaseResponse> {
tracing::debug!(
"store: {:?}, transaction_id: {}, payload: {}",
receipt.store,
&receipt.transaction_id,
&receipt.payload,
);
match receipt.store {
Platform::AppleAppStore => {
let response = apple::fetch_apple_receipt_data_with_urls(
receipt,
&self.apple_urls,
self.secret.as_ref(),
)
.await?;
if response.status == 0 {
if response.is_subscription(&receipt.transaction_id) {
Ok(validate_apple_subscription(
&response,
&receipt.transaction_id,
now,
))
} else {
Ok(validate_apple_package(&response, &receipt.transaction_id))
}
} else {
Ok(PurchaseResponse {
valid: false,
product_id: response.get_product_id(&receipt.transaction_id),
})
}
}
Platform::GooglePlay => {
if let Ok((Ok(response_future), sku_type)) =
google::GooglePlayData::from(&receipt.payload).and_then(|data| {
data.get_sku_details().map(|sku_details| {
let sku_type = sku_details.sku_type;
(
data.get_uri(&sku_type).map(|uri| {
fetch_google_receipt_data_with_uri(
self.service_account_key.as_ref(),
uri,
Some(data),
)
}),
sku_type,
)
})
})
{
if let Ok(response) = response_future.await {
match sku_type {
google::SkuType::Subs => validate_google_subscription(&response, now),
google::SkuType::Inapp => Ok(validate_google_package(&response)),
}
} else {
Ok(PurchaseResponse {
valid: false,
product_id: None,
})
}
} else {
Ok(PurchaseResponse {
valid: false,
product_id: None,
})
}
}
}
}
}
#[async_trait]
impl ReceiptDataFetcher for UnityPurchaseValidator<'_> {
async fn fetch_apple_receipt_data(
&self,
receipt: &UnityPurchaseReceipt,
) -> Result<AppleResponse> {
fetch_apple_receipt_data_with_urls(receipt, &self.apple_urls, self.secret.as_ref()).await
}
async fn fetch_google_receipt_data(
&self,
receipt: &UnityPurchaseReceipt,
) -> Result<(GoogleResponse, SkuType)> {
let data = google::GooglePlayData::from(&receipt.payload)?;
let sku_type = data.get_sku_details()?.sku_type;
fetch_google_receipt_data_with_uri(
self.service_account_key.as_ref(),
data.get_uri(&sku_type)?,
Some(data),
)
.await
.map(|response| (response, sku_type))
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
apple::{AppleInAppReceipt, AppleReceipt, AppleResponse},
google::{validate_google_subscription, GoogleResponse},
};
use chrono::{Duration, Utc};
use mockito::mock;
use serial_test::serial;
fn new_for_test<'a>(prod_url: &'a str, sandbox_url: &'a str) -> UnityPurchaseValidator<'a> {
UnityPurchaseValidator {
secret: Some(String::from("secret")),
apple_urls: AppleUrls {
production: prod_url,
sandbox: sandbox_url,
},
service_account_key: None,
}
}
#[tokio::test]
#[serial]
async fn test_sandbox_response() {
let expiry = (Utc::now() + Duration::days(1))
.timestamp_millis()
.to_string();
let apple_response = AppleResponse {
receipt: Some(AppleReceipt {
in_app: Some(vec![AppleInAppReceipt {
product_id: Some("prod".to_string()),
expires_date_ms: Some(expiry),
transaction_id: Some("txn".to_string()),
..AppleInAppReceipt::default()
}]),
..AppleReceipt::default()
}),
..AppleResponse::default()
};
let _m1 = mock("POST", "/sb/verifyReceipt")
.with_status(200)
.with_body(&serde_json::to_string(&apple_response).unwrap())
.create();
let _m2 = mock("POST", "/verifyReceipt")
.with_status(200)
.with_body(r#"{"status": 21007}"#)
.create();
let url = &mockito::server_url();
let sandbox = format!("{}/sb", url);
let validator = new_for_test(url, &sandbox);
let response = validator
.validate(
Utc::now(),
&UnityPurchaseReceipt {
transaction_id: "txn".to_string(),
..UnityPurchaseReceipt::default()
},
)
.await
.unwrap();
assert!(response.valid);
assert_eq!(response.product_id, Some("prod".to_string()));
}
#[tokio::test]
#[serial]
async fn test_validate_empty_transaction() {
let expiry = (Utc::now() + Duration::days(1))
.timestamp_millis()
.to_string();
let apple_response = AppleResponse {
receipt: Some(AppleReceipt {
in_app: Some(vec![
AppleInAppReceipt {
product_id: Some("prod".to_string()),
expires_date_ms: Some(Utc::now().timestamp_millis().to_string()),
transaction_id: Some("txn3".to_string()),
..AppleInAppReceipt::default()
},
AppleInAppReceipt {
product_id: Some("prod".to_string()),
expires_date_ms: Some(expiry),
transaction_id: Some("txn1".to_string()),
..AppleInAppReceipt::default()
},
AppleInAppReceipt {
product_id: Some("prod".to_string()),
expires_date_ms: Some(
(Utc::now() - Duration::days(1))
.timestamp_millis()
.to_string(),
),
transaction_id: Some("txn2".to_string()),
..AppleInAppReceipt::default()
},
]),
..AppleReceipt::default()
}),
..AppleResponse::default()
};
let _m1 = mock("POST", "/sb/verifyReceipt")
.with_status(200)
.with_body(&serde_json::to_string(&apple_response).unwrap())
.create();
let _m2 = mock("POST", "/verifyReceipt")
.with_status(200)
.with_body(r#"{"status": 21007}"#)
.create();
let url = &mockito::server_url();
let sandbox = format!("{}/sb", url);
let validator = new_for_test(url, &sandbox);
let response = validator
.validate(
Utc::now(),
&UnityPurchaseReceipt {
transaction_id: "".to_string(),
..UnityPurchaseReceipt::default()
},
)
.await
.unwrap();
assert!(response.valid);
assert_eq!(response.product_id, Some("prod".to_string()));
}
#[tokio::test]
#[serial]
async fn test_subscription_expired_fallback() {
let expiry = (Utc::now() + Duration::days(1))
.timestamp_millis()
.to_string();
let apple_response = AppleResponse {
receipt: Some(AppleReceipt {
in_app: Some(vec![
AppleInAppReceipt {
product_id: Some("prod".to_string()),
expires_date_ms: Some(expiry),
transaction_id: Some("txn1".to_string()),
..AppleInAppReceipt::default()
},
AppleInAppReceipt {
product_id: Some("prod".to_string()),
expires_date_ms: Some(
(Utc::now() - Duration::days(1))
.timestamp_millis()
.to_string(),
),
transaction_id: Some("txn2".to_string()),
..AppleInAppReceipt::default()
},
]),
..AppleReceipt::default()
}),
..AppleResponse::default()
};
let _m1 = mock("POST", "/sb/verifyReceipt")
.with_status(200)
.with_body(&serde_json::to_string(&apple_response).unwrap())
.create();
let _m2 = mock("POST", "/verifyReceipt")
.with_status(200)
.with_body(r#"{"status": 21007}"#)
.create();
let url = &mockito::server_url();
let sandbox = format!("{}/sb", url);
let validator = new_for_test(url, &sandbox);
let response = validator
.validate(
Utc::now(),
&UnityPurchaseReceipt {
transaction_id: "txn2".to_string(),
..UnityPurchaseReceipt::default()
},
)
.await
.unwrap();
assert!(response.valid);
assert_eq!(response.product_id, Some("prod".to_string()));
}
#[tokio::test]
#[serial]
async fn test_invalid_receipt() {
let now = Utc::now().timestamp_millis().to_string();
let apple_response = AppleResponse {
latest_receipt: Some(String::default()),
receipt: Some(AppleReceipt {
in_app: Some(vec![AppleInAppReceipt {
product_id: Some("prod".to_string()),
expires_date_ms: Some(now),
transaction_id: Some("txn".to_string()),
..AppleInAppReceipt::default()
}]),
..AppleReceipt::default()
}),
..AppleResponse::default()
};
let _m = mock("POST", "/verifyReceipt")
.with_status(200)
.with_body(&serde_json::to_string(&apple_response).unwrap())
.create();
let url = &mockito::server_url();
let sandbox = format!("{}/sb", url);
let validator = new_for_test(url, &sandbox);
assert!(
!validator
.validate(
Utc::now(),
&UnityPurchaseReceipt {
transaction_id: "txn".to_string(),
..UnityPurchaseReceipt::default()
}
)
.await
.unwrap()
.valid
);
}
#[tokio::test]
#[serial]
async fn test_apple_purchase() {
let apple_response = AppleResponse {
receipt: Some(AppleReceipt {
in_app: Some(vec![
AppleInAppReceipt {
product_id: Some("prod".to_string()),
transaction_id: Some("not_txn".to_string()),
..AppleInAppReceipt::default()
},
AppleInAppReceipt {
product_id: Some("prod".to_string()),
transaction_id: Some("txn".to_string()),
..AppleInAppReceipt::default()
},
]),
..AppleReceipt::default()
}),
..AppleResponse::default()
};
let _m1 = mock("POST", "/sb/verifyReceipt")
.with_status(200)
.with_body(&serde_json::to_string(&apple_response).unwrap())
.create();
let _m2 = mock("POST", "/verifyReceipt")
.with_status(200)
.with_body(r#"{"status": 21007}"#)
.create();
let url = &mockito::server_url();
let sandbox = format!("{}/sb", url);
let validator = new_for_test(url, &sandbox);
let response = validator
.validate(
Utc::now(),
&UnityPurchaseReceipt {
transaction_id: "txn".to_string(),
..UnityPurchaseReceipt::default()
},
)
.await
.unwrap();
assert!(response.valid);
assert_eq!(response.product_id, Some("prod".to_string()));
}
#[tokio::test]
#[serial]
async fn test_apple_purchase_failed_missing_txn() {
let apple_response = AppleResponse {
receipt: Some(AppleReceipt {
in_app: Some(vec![AppleInAppReceipt {
product_id: Some("prod".to_string()),
transaction_id: Some("not_txn".to_string()),
..AppleInAppReceipt::default()
}]),
..AppleReceipt::default()
}),
..AppleResponse::default()
};
let _m1 = mock("POST", "/sb/verifyReceipt")
.with_status(200)
.with_body(&serde_json::to_string(&apple_response).unwrap())
.create();
let _m2 = mock("POST", "/verifyReceipt")
.with_status(200)
.with_body(r#"{"status": 21007}"#)
.create();
let url = &mockito::server_url();
let sandbox = format!("{}/sb", url);
let validator = new_for_test(url, &sandbox);
assert!(
!validator
.validate(
Utc::now(),
&UnityPurchaseReceipt {
transaction_id: "txn".to_string(),
..UnityPurchaseReceipt::default()
}
)
.await
.unwrap()
.valid
);
}
#[tokio::test]
#[serial]
async fn test_apple_fail() {
let _m = mock("POST", "/verifyReceipt")
.with_status(200)
.with_body(r#"{"status": 333}"#)
.create();
let url = &mockito::server_url();
let sandbox = format!("{}/sb", url);
let validator = new_for_test(url, &sandbox);
assert!(
!validator
.validate(Utc::now(), &UnityPurchaseReceipt::default())
.await
.unwrap()
.valid
);
}
#[tokio::test]
#[serial]
async fn test_google_fail() {
let google_response = GoogleResponse {
expiry_time: Some(Utc::now().timestamp_millis().to_string()),
..GoogleResponse::default()
};
let _m = mock("GET", "/test")
.with_status(200)
.with_body(&serde_json::to_string(&google_response).unwrap())
.create();
let url = &mockito::server_url();
assert!(
!validate_google_subscription(
&google::fetch_google_receipt_data_with_uri(None, url.clone(), None,)
.await
.unwrap(),
Utc::now()
)
.unwrap()
.valid
);
}
#[test]
fn test_deserialize_apple() {
let file = std::fs::read("res/test_apple.json").unwrap();
let apple_response: AppleResponse = serde_json::from_slice(&file).unwrap();
assert!(apple_response.latest_receipt.is_some());
assert!(apple_response.latest_receipt_info.is_some());
assert!(apple_response.environment.is_some());
}
#[test]
fn test_deserialize_google() {
let file = std::fs::read("res/test_google.json").unwrap();
let _google_response: GoogleResponse = serde_json::from_slice(&file).unwrap();
}
#[tokio::test]
#[serial]
async fn test_google() {
let google_response = GoogleResponse {
product_id: Some("prod".to_string()),
expiry_time: Some(
(Utc::now() + Duration::days(1))
.timestamp_millis()
.to_string(),
),
..GoogleResponse::default()
};
let _m = mock("GET", "/test")
.with_status(200)
.with_body(&serde_json::to_string(&google_response).unwrap())
.create();
let url = &mockito::server_url();
let response = validate_google_subscription(
&google::fetch_google_receipt_data_with_uri(None, url.clone(), None)
.await
.unwrap(),
Utc::now(),
)
.unwrap();
assert!(response.valid);
assert_eq!(response.product_id, Some("prod".to_string()));
}
#[tokio::test]
#[serial]
async fn test_google_purchase() {
let google_response = GoogleResponse {
purchase_state: Some(0),
product_id: Some("prod".to_string()),
..GoogleResponse::default()
};
let _m = mock("GET", "/test")
.with_status(200)
.with_body(&serde_json::to_string(&google_response).unwrap())
.create();
let url = &mockito::server_url();
let response = validate_google_package(
&google::fetch_google_receipt_data_with_uri(None, url.clone(), None)
.await
.unwrap(),
);
assert!(response.valid);
assert_eq!(response.product_id, Some("prod".to_string()));
}
}