#![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)]
mod apple;
mod google;
pub mod error;
use async_trait::async_trait;
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_subscription,
AppleResponse, AppleUrls,
};
pub use google::{
fetch_google_receipt_data, fetch_google_receipt_data_with_uri, validate_google_subscription,
GoogleResponse,
};
#[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,
}
#[async_trait]
pub trait Validator: Send + Sync {
async fn validate(&self, 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>;
}
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)]
#[allow(clippy::must_use_candidate)]
pub fn set_apple_secret(self, secret: String) -> Self {
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, receipt: &UnityPurchaseReceipt) -> Result<PurchaseResponse> {
log::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 {
match response.latest_receipt {
Some(_) => Ok(validate_apple_subscription(&response)),
None => unimplemented!("validate consumable"),
}
} else {
Ok(PurchaseResponse { valid: false })
}
}
Platform::GooglePlay => {
if let Ok((Ok(response_future), Ok(sku_type))) =
google::GooglePlayData::from(&receipt.payload).map(|data| {
(
data.get_uri().map(|uri| {
fetch_google_receipt_data_with_uri(
self.service_account_key.as_ref(),
uri,
)
}),
data.get_sku_details()
.map(|sku_details| sku_details.sku_type),
)
})
{
if let Ok(response) = response_future.await {
if sku_type == "subs" {
validate_google_subscription(&response)
} else {
unimplemented!("validate consumable")
}
} else {
Ok(PurchaseResponse { valid: false })
}
} else {
Ok(PurchaseResponse { valid: false })
}
}
}
}
}
#[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> {
fetch_google_receipt_data_with_uri(
self.service_account_key.as_ref(),
google::GooglePlayData::from(&receipt.payload)?.get_uri()?,
)
.await
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::{
apple::{AppleLatestReceipt, 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 apple_response = AppleResponse {
latest_receipt: Some(String::default()),
latest_receipt_info: Some(vec![AppleLatestReceipt {
expires_date_ms: (Utc::now() + Duration::days(1))
.timestamp_millis()
.to_string(),
..AppleLatestReceipt::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(&UnityPurchaseReceipt::default())
.await
.unwrap()
.valid
);
}
#[tokio::test]
#[serial]
async fn test_invalid_receipt() {
let apple_response = AppleResponse {
latest_receipt: Some(String::default()),
latest_receipt_info: Some(vec![AppleLatestReceipt {
expires_date_ms: Utc::now().timestamp_millis().to_string(),
..AppleLatestReceipt::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(&UnityPurchaseReceipt::default())
.await
.unwrap()
.valid
);
}
#[tokio::test]
#[serial]
async fn test_most_recent_receipt() {
let now = Utc::now().timestamp_millis();
let day = Duration::days(1).num_milliseconds();
let latest_receipt_info = vec![
AppleLatestReceipt {
expires_date_ms: now.to_string(),
..AppleLatestReceipt::default()
},
AppleLatestReceipt {
expires_date_ms: (now + day).to_string(),
..AppleLatestReceipt::default()
},
AppleLatestReceipt {
expires_date_ms: (now - day).to_string(),
..AppleLatestReceipt::default()
},
];
let apple_response = AppleResponse {
latest_receipt: Some(String::default()),
latest_receipt_info: Some(latest_receipt_info),
..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(&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(&UnityPurchaseReceipt::default())
.await
.unwrap()
.valid
);
}
#[tokio::test]
#[serial]
async fn test_google_fail() {
let google_response = GoogleResponse {
expiry_time: 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())
.await
.unwrap()
)
.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 {
expiry_time: (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();
assert!(
validate_google_subscription(
&google::fetch_google_receipt_data_with_uri(None, url.clone())
.await
.unwrap()
)
.unwrap()
.valid
);
}
}