use serde::{Deserialize, Serialize};
#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub struct AuthQuery {
pub code: String,
pub shop: String,
pub timestamp: String,
pub state: String,
pub host: String,
pub hmac: String,
}
impl AuthQuery {
#[must_use]
pub const fn new(
code: String,
shop: String,
timestamp: String,
state: String,
host: String,
hmac: String,
) -> Self {
Self {
code,
shop,
timestamp,
state,
host,
hmac,
}
}
#[must_use]
pub fn to_signable_string(&self) -> String {
let mut params: Vec<(&str, &str)> = vec![
("code", &self.code),
("host", &self.host),
("shop", &self.shop),
("state", &self.state),
("timestamp", &self.timestamp),
];
params.sort_by_key(|(key, _)| *key);
params
.iter()
.map(|(key, value)| format!("{}={}", key, urlencoding::encode(value)))
.collect::<Vec<_>>()
.join("&")
}
}
const _: fn() = || {
const fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<AuthQuery>();
};
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_auth_query_creation_with_all_fields() {
let query = AuthQuery::new(
"abc123".to_string(),
"my-shop.myshopify.com".to_string(),
"1699999999".to_string(),
"state-param".to_string(),
"host-base64".to_string(),
"hmac-value".to_string(),
);
assert_eq!(query.code, "abc123");
assert_eq!(query.shop, "my-shop.myshopify.com");
assert_eq!(query.timestamp, "1699999999");
assert_eq!(query.state, "state-param");
assert_eq!(query.host, "host-base64");
assert_eq!(query.hmac, "hmac-value");
}
#[test]
fn test_to_signable_string_sorts_alphabetically() {
let query = AuthQuery::new(
"z-code".to_string(),
"a-shop.myshopify.com".to_string(),
"1234567890".to_string(),
"m-state".to_string(),
"b-host".to_string(),
"ignored-hmac".to_string(),
);
let signable = query.to_signable_string();
let parts: Vec<&str> = signable.split('&').collect();
assert_eq!(parts.len(), 5);
assert!(parts[0].starts_with("code="));
assert!(parts[1].starts_with("host="));
assert!(parts[2].starts_with("shop="));
assert!(parts[3].starts_with("state="));
assert!(parts[4].starts_with("timestamp="));
}
#[test]
fn test_to_signable_string_uri_encodes_values() {
let query = AuthQuery::new(
"code with spaces".to_string(),
"shop.myshopify.com".to_string(),
"1234567890".to_string(),
"state=special&chars".to_string(),
"host+plus".to_string(),
"hmac".to_string(),
);
let signable = query.to_signable_string();
assert!(signable.contains("code%20with%20spaces"));
assert!(signable.contains("state%3Dspecial%26chars"));
assert!(signable.contains("host%2Bplus"));
}
#[test]
fn test_to_signable_string_excludes_hmac() {
let query = AuthQuery::new(
"code".to_string(),
"shop.myshopify.com".to_string(),
"12345".to_string(),
"state".to_string(),
"host".to_string(),
"this-should-not-appear".to_string(),
);
let signable = query.to_signable_string();
assert!(!signable.contains("hmac"));
assert!(!signable.contains("this-should-not-appear"));
}
#[test]
fn test_auth_query_fields_match_expected_structure() {
let query = AuthQuery {
code: "c".to_string(),
shop: "s".to_string(),
timestamp: "t".to_string(),
state: "st".to_string(),
host: "h".to_string(),
hmac: "hm".to_string(),
};
assert_eq!(query.code, "c");
assert_eq!(query.shop, "s");
assert_eq!(query.timestamp, "t");
assert_eq!(query.state, "st");
assert_eq!(query.host, "h");
assert_eq!(query.hmac, "hm");
}
#[test]
fn test_auth_query_serialization() {
let query = AuthQuery::new(
"code".to_string(),
"shop.myshopify.com".to_string(),
"12345".to_string(),
"state".to_string(),
"host".to_string(),
"hmac".to_string(),
);
let json = serde_json::to_string(&query).unwrap();
assert!(json.contains("\"code\":\"code\""));
assert!(json.contains("\"shop\":\"shop.myshopify.com\""));
}
#[test]
fn test_auth_query_deserialization() {
let json = r#"{
"code": "auth-code",
"shop": "test.myshopify.com",
"timestamp": "1700000000",
"state": "test-state",
"host": "test-host",
"hmac": "test-hmac"
}"#;
let query: AuthQuery = serde_json::from_str(json).unwrap();
assert_eq!(query.code, "auth-code");
assert_eq!(query.shop, "test.myshopify.com");
assert_eq!(query.hmac, "test-hmac");
}
#[test]
fn test_auth_query_clone() {
let query = AuthQuery::new(
"code".to_string(),
"shop".to_string(),
"time".to_string(),
"state".to_string(),
"host".to_string(),
"hmac".to_string(),
);
let cloned = query.clone();
assert_eq!(query, cloned);
}
#[test]
fn test_auth_query_is_send_sync() {
fn assert_send_sync<T: Send + Sync>() {}
assert_send_sync::<AuthQuery>();
}
#[test]
fn test_signable_string_format() {
let query = AuthQuery::new(
"0907a61c0c8d55e99db179b68161bc00".to_string(),
"some-shop.myshopify.com".to_string(),
"1337178173".to_string(),
"123".to_string(),
"dGVzdC5teXNob3BpZnkuY29tL2FkbWlu".to_string(),
"expected-hmac".to_string(),
);
let signable = query.to_signable_string();
assert!(signable.contains("="));
assert!(signable.contains("&"));
assert!(signable.contains("code="));
assert!(signable.contains("host="));
assert!(signable.contains("shop="));
assert!(signable.contains("state="));
assert!(signable.contains("timestamp="));
}
}