1use reqwest::Client;
3use serde::{Deserialize, Serialize};
4use thiserror::Error;
5use crate::{
6 config::{Config, EnvironmentType},
7 csr::Csr,
8 invoice::SignedInvoice,
9};
10use std::marker::PhantomData;
11
12#[derive(Error, Debug)]
14pub enum ZatcaError {
15 #[error("Network error: {0}")]
16 NetworkError(String),
17 #[error("Invalid response from ZATCA: {0}")]
18 InvalidResponse(String),
19 #[error("Unauthorized: {0:?}")]
20 Unauthorized(UnauthorizedResponse),
21 #[error("Server error: {0:?}")]
22 ServerError(ServerErrorResponse),
23 #[error("HTTP client error: {0}")]
24 Http(String),
25 #[error("Client state error: {0}")]
26 ClientState(String),
27}
28
29pub trait TokenScope {}
31#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
32pub struct Compliance;
34#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)]
35pub struct Production;
37impl TokenScope for Compliance {}
38impl TokenScope for Production {}
39
40#[derive(Debug)]
53pub struct ZatcaClient {
54 config: Config,
55 _client: Client,
56 base_url: String,
57}
58
59#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
61pub struct ValidationResponse {
62 #[serde(rename = "validationResults")]
63 validation_results: ValidationResults,
64 #[serde(rename = "reportingStatus")]
65 reporting_status: Option<String>,
66 #[serde(rename = "clearanceStatus")]
67 clearance_status: Option<String>,
68 #[serde(rename = "qrSellertStatus")]
69 qr_seller_status: Option<String>,
70 #[serde(rename = "qrBuyertStatus")]
71 qr_buyer_status: Option<String>,
72}
73
74impl ValidationResponse {
75 pub fn validation_results(&self) -> &ValidationResults {
76 &self.validation_results
77 }
78
79 pub fn reporting_status(&self) -> Option<&str> {
80 self.reporting_status.as_deref()
81 }
82
83 pub fn clearance_status(&self) -> Option<&str> {
84 self.clearance_status.as_deref()
85 }
86
87 pub fn qr_seller_status(&self) -> Option<&str> {
88 self.qr_seller_status.as_deref()
89 }
90
91 pub fn qr_buyer_status(&self) -> Option<&str> {
92 self.qr_buyer_status.as_deref()
93 }
94}
95
96#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
98pub struct ValidationResults {
99 #[serde(rename = "infoMessages", default)]
100 info_messages: MessageList,
101 #[serde(rename = "warningMessages", default)]
102 warning_messages: Vec<ValidationMessage>,
103 #[serde(rename = "errorMessages", default)]
104 error_messages: Vec<ValidationMessage>,
105 #[serde(default)]
106 status: Option<String>,
107}
108
109impl ValidationResults {
110 pub fn info_messages(&self) -> &MessageList {
111 &self.info_messages
112 }
113
114 pub fn warning_messages(&self) -> &[ValidationMessage] {
115 &self.warning_messages
116 }
117
118 pub fn error_messages(&self) -> &[ValidationMessage] {
119 &self.error_messages
120 }
121
122 pub fn status(&self) -> Option<&str> {
123 self.status.as_deref()
124 }
125}
126
127#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
129pub struct ValidationMessage {
130 #[serde(rename = "type")]
131 message_type: Option<String>,
132 code: Option<String>,
133 category: Option<String>,
134 message: Option<String>,
135 #[serde(default)]
136 status: Option<String>,
137}
138
139impl ValidationMessage {
140 pub fn message_type(&self) -> Option<&str> {
141 self.message_type.as_deref()
142 }
143
144 pub fn code(&self) -> Option<&str> {
145 self.code.as_deref()
146 }
147
148 pub fn category(&self) -> Option<&str> {
149 self.category.as_deref()
150 }
151
152 pub fn message(&self) -> Option<&str> {
153 self.message.as_deref()
154 }
155
156 pub fn status(&self) -> Option<&str> {
157 self.status.as_deref()
158 }
159}
160
161#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
162#[serde(untagged)]
163#[derive(Default)]
164pub enum MessageList {
166 One(ValidationMessage),
167 Many(Vec<ValidationMessage>),
168 #[default]
169 Empty,
170}
171
172#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
174pub struct UnauthorizedResponse {
175 timestamp: Option<i64>,
176 status: Option<u16>,
177 error: Option<String>,
178 message: Option<String>,
179}
180
181impl UnauthorizedResponse {
182 pub fn timestamp(&self) -> Option<i64> {
183 self.timestamp
184 }
185
186 pub fn status(&self) -> Option<u16> {
187 self.status
188 }
189
190 pub fn error(&self) -> Option<&str> {
191 self.error.as_deref()
192 }
193
194 pub fn message(&self) -> Option<&str> {
195 self.message.as_deref()
196 }
197}
198
199#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
201pub struct ServerErrorResponse {
202 category: Option<String>,
203 code: Option<String>,
204 message: Option<String>,
205}
206
207impl ServerErrorResponse {
208 pub fn category(&self) -> Option<&str> {
209 self.category.as_deref()
210 }
211
212 pub fn code(&self) -> Option<&str> {
213 self.code.as_deref()
214 }
215
216 pub fn message(&self) -> Option<&str> {
217 self.message.as_deref()
218 }
219}
220
221
222#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
240pub struct CsidCredentials<T> {
241 env: EnvironmentType,
242 request_id: Option<String>,
243 binary_security_token: String,
244 secret: String,
245 #[serde(skip)]
246 _marker: PhantomData<T>,
247}
248
249impl<T> CsidCredentials<T> {
250 pub fn new(
252 env: EnvironmentType,
253 request_id: Option<String>,
254 binary_security_token: impl Into<String>,
255 secret: impl Into<String>,
256 ) -> Self {
257 Self {
258 env,
259 request_id,
260 binary_security_token: binary_security_token.into(),
261 secret: secret.into(),
262 _marker: PhantomData,
263 }
264 }
265
266 pub fn env(&self) -> EnvironmentType {
267 self.env
268 }
269
270 pub fn request_id(&self) -> Option<&str> {
271 self.request_id.as_deref()
272 }
273
274 pub fn binary_security_token(&self) -> &str {
275 &self.binary_security_token
276 }
277
278 pub fn secret(&self) -> &str {
279 &self.secret
280 }
281}
282
283#[derive(Debug, Deserialize)]
284struct CsidResponseBody {
285 #[serde(rename = "requestID")]
286 #[serde(deserialize_with = "deserialize_request_id")]
287 request_id: Option<String>,
288 #[serde(rename = "binarySecurityToken")]
289 binary_security_token: String,
290 secret: String,
291 #[allow(dead_code)]
292 #[serde(rename = "tokenType")]
293 token_type: Option<String>,
294 #[allow(dead_code)]
295 #[serde(rename = "dispositionMessage")]
296 disposition_message: Option<String>,
297}
298
299fn deserialize_request_id<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
300where
301 D: serde::Deserializer<'de>,
302{
303 let value: Option<serde_json::Value> = Option::deserialize(deserializer)?;
304 match value {
305 None => Ok(None),
306 Some(serde_json::Value::String(value)) => Ok(Some(value)),
307 Some(serde_json::Value::Number(value)) => Ok(Some(value.to_string())),
308 Some(other) => Err(serde::de::Error::custom(format!(
309 "invalid requestID type: {other}"
310 ))),
311 }
312}
313
314#[derive(Debug, Deserialize)]
315#[serde(untagged)]
316enum RenewalResponseBody {
317 Direct(CsidResponseBody),
318 Wrapped { value: CsidResponseBody },
319}
320
321impl ZatcaClient {
323 pub fn new(config: Config) -> Result<Self, ZatcaError> {
328 let client = Client::builder()
329 .build()
330 .map_err(|e| ZatcaError::Http(e.to_string()))?;
331 let base_url = std::env::var("FATOORA_ZATCA_BASE_URL")
332 .ok()
333 .map(|value| {
334 if value.ends_with('/') {
335 value
336 } else {
337 format!("{value}/")
338 }
339 })
340 .unwrap_or_else(|| config.env().endpoint_url().to_string());
341
342 Ok(Self {
343 config,
344 _client: client,
345 base_url,
346 })
347 }
348
349 pub async fn report_simplified_invoice(
355 &self,
356 invoice: &SignedInvoice,
357 credentials: &CsidCredentials<Production>,
358 clearance_status: bool,
359 accept_language: Option<&str>,
360 ) -> Result<ValidationResponse, ZatcaError> {
361 self.ensure_env(credentials)?;
362 if !invoice.data().invoice_type().is_simplified() {
363 return Err(ZatcaError::ClientState(
364 "Reporting only supports simplified invoices".into(),
365 ));
366 }
367
368 let payload = serde_json::json!({
369 "invoiceHash": invoice.invoice_hash(),
370 "uuid": invoice.uuid(),
371 "invoice": invoice.to_xml_base64()
372 });
373 let url = self.build_endpoint("invoices/reporting/single");
374 let mut request = self
375 ._client
376 .post(url)
377 .header("Accept", "application/json")
378 .header("Accept-Version", "V2")
379 .header("Content-Type", "application/json")
380 .header("Clearance-Status", if clearance_status { "1" } else { "0" })
381 .basic_auth(
382 credentials.binary_security_token().to_string(),
383 Some(credentials.secret().to_string()),
384 )
385 .json(&payload);
386
387 match accept_language {
388 Some("ar") => request = request.header("accept-language", "ar"),
389 _ => request = request.header("accept-language", "en")
390 }
391
392 let response = request
393 .send()
394 .await
395 .map_err(|e| ZatcaError::Http(e.to_string()))?;
396 let status = response.status();
397 let body = response.text().await.unwrap_or_default();
398
399 if status.is_success() || status.as_u16() == 400 || status.as_u16() == 409 {
400 match serde_json::from_str::<ValidationResponse>(&body) {
401 Ok(parsed) => return Ok(parsed),
402 Err(_) => {
403 return Err(ZatcaError::InvalidResponse(format!(
404 "status {status}: {body}"
405 )))
406 }
407 }
408 }
409
410 if status.as_u16() == 406 {
411 return Err(ZatcaError::InvalidResponse(format!(
412 "status {status}: {body}"
413 )));
414 }
415
416 if status.as_u16() == 401 {
417 let parsed = serde_json::from_str::<UnauthorizedResponse>(&body).unwrap_or_else(|_| {
418 UnauthorizedResponse {
419 timestamp: None,
420 status: Some(401),
421 error: Some("Unauthorized".into()),
422 message: Some(body.clone()),
423 }
424 });
425 return Err(ZatcaError::Unauthorized(parsed));
426 }
427
428 if status.is_server_error() {
429 let parsed = serde_json::from_str::<ServerErrorResponse>(&body).unwrap_or_else(|_| {
430 ServerErrorResponse {
431 category: None,
432 code: Some("ServerError".into()),
433 message: Some(body.clone()),
434 }
435 });
436 return Err(ZatcaError::ServerError(parsed));
437 }
438
439 Err(ZatcaError::InvalidResponse(format!(
440 "status {status}: {body}"
441 )))
442 }
443
444 pub async fn clear_standard_invoice(
450 &self,
451 invoice: &SignedInvoice,
452 credentials: &CsidCredentials<Production>,
453 clearance_status: bool,
454 accept_language: Option<&str>,
455 ) -> Result<ValidationResponse, ZatcaError> {
456 self.ensure_env(credentials)?;
457 if invoice.data().invoice_type().is_simplified() {
458 return Err(ZatcaError::ClientState(
459 "Clearance only supports standard invoices".into(),
460 ));
461 }
462
463 let payload = serde_json::json!({
464 "invoiceHash": invoice.invoice_hash(),
465 "uuid": invoice.uuid(),
466 "invoice": invoice.to_xml_base64()
467 });
468 let url = self.build_endpoint("invoices/clearance/single");
469 let mut request = self
470 ._client
471 .post(url)
472 .header("Accept", "application/json")
473 .header("Accept-Version", "V2")
474 .header("Content-Type", "application/json")
475 .header("Clearance-Status", if clearance_status { "1" } else { "0" })
476 .basic_auth(
477 credentials.binary_security_token().to_string(),
478 Some(credentials.secret().to_string()),
479 )
480 .json(&payload);
481
482 match accept_language {
483 Some("ar") => request = request.header("accept-language", "ar"),
484 _ => request = request.header("accept-language", "en"),
485 }
486
487 let response = request
488 .send()
489 .await
490 .map_err(|e| ZatcaError::Http(e.to_string()))?;
491 let status = response.status();
492 let body = response.text().await.unwrap_or_default();
493
494 if status.is_success() || status.as_u16() == 400 {
495 match serde_json::from_str::<ValidationResponse>(&body) {
496 Ok(parsed) => return Ok(parsed),
497 Err(_) => {
498 return Err(ZatcaError::InvalidResponse(format!(
499 "status {status}: {body}"
500 )))
501 }
502 }
503 }
504
505 if status.as_u16() == 401 {
506 let parsed = serde_json::from_str::<UnauthorizedResponse>(&body).unwrap_or_else(|_| {
507 UnauthorizedResponse {
508 timestamp: None,
509 status: Some(401),
510 error: Some("Unauthorized".into()),
511 message: Some(body.clone()),
512 }
513 });
514 return Err(ZatcaError::Unauthorized(parsed));
515 }
516
517 if status.is_server_error() {
518 let parsed = serde_json::from_str::<ServerErrorResponse>(&body).unwrap_or_else(|_| {
519 ServerErrorResponse {
520 category: None,
521 code: Some("ServerError".into()),
522 message: Some(body.clone()),
523 }
524 });
525 return Err(ZatcaError::ServerError(parsed));
526 }
527
528 Err(ZatcaError::InvalidResponse(format!(
529 "status {status}: {body}"
530 )))
531 }
532
533 pub async fn check_invoice_compliance(
539 &self,
540 invoice: &SignedInvoice,
541 credentials: &CsidCredentials<Compliance>,
542 ) -> Result<ValidationResponse, ZatcaError> {
543 self.ensure_env(credentials)?;
544 let payload = serde_json::json!({
545 "invoiceHash": invoice.invoice_hash(),
546 "uuid": invoice.uuid(),
547 "invoice": invoice.to_xml_base64()
548 });
549
550 let url = self.build_endpoint("compliance/invoices");
551
552 let response = self
553 ._client
554 .post(url)
555 .header("Accept", "application/json")
556 .header("Accept-Language", "en")
557 .header("Accept-Version", "V2")
558 .header("Content-Type", "application/json")
559 .basic_auth(
560 credentials.binary_security_token().to_string(),
561 Some(credentials.secret().to_string()),
562 )
563 .json(&payload)
564 .send()
565 .await
566 .map_err(|e| ZatcaError::Http(e.to_string()))?;
567
568 let status = response.status();
569 let body = response.text().await.unwrap_or_default();
570
571 if status.is_success() || status.as_u16() == 400 {
572 let parsed = serde_json::from_str::<ValidationResponse>(&body)
573 .map_err(|e| ZatcaError::InvalidResponse(format!("Invalid response: {e:?}")))?;
574 return Ok(parsed);
575 }
576
577 if status.as_u16() == 401 {
578 let parsed = serde_json::from_str::<UnauthorizedResponse>(&body).unwrap_or_else(|_| {
579 UnauthorizedResponse {
580 timestamp: None,
581 status: Some(401),
582 error: Some("Unauthorized".into()),
583 message: Some(body.clone()),
584 }
585 });
586 return Err(ZatcaError::Unauthorized(parsed));
587 }
588
589 if status.is_server_error() {
590 let parsed = serde_json::from_str::<ServerErrorResponse>(&body).unwrap_or_else(|_| {
591 ServerErrorResponse {
592 category: None,
593 code: Some("ServerError".into()),
594 message: Some(body.clone()),
595 }
596 });
597 return Err(ZatcaError::ServerError(parsed));
598 }
599
600 Err(ZatcaError::InvalidResponse(format!(
601 "status {status}: {body}"
602 )))
603 }
604 pub async fn post_csr_for_ccsid(
611 &self,
612 csr: &Csr,
613 otp: &str,
614 ) -> Result<CsidCredentials<Compliance>, ZatcaError> {
615 let encoded_csr = csr
616 .to_pem_base64()
617 .map_err(|e| ZatcaError::InvalidResponse(e.to_string()))?;
618 let csr_payload = serde_json::json!({ "csr": encoded_csr });
619 let url = self.build_endpoint("compliance");
620 let response = self
621 ._client
622 .post(url)
623 .header("Accept", "application/json")
624 .header("OTP", otp)
625 .header("Accept-Version", "V2")
626 .header("Content-Type", "application/json")
627 .json(&csr_payload)
628 .send()
629 .await
630 .map_err(|e| ZatcaError::Http(e.to_string()))?;
631 let status = response.status();
632
633 if !status.is_success() {
634 let message = response.text().await.unwrap_or_default();
635 return Err(ZatcaError::InvalidResponse(format!(
636 "status {status}: {message}"
637 )));
638 }
639
640 let payload: CsidResponseBody = response
641 .json()
642 .await
643 .map_err(|e| ZatcaError::InvalidResponse(e.to_string()))?;
644 Ok(CsidCredentials::new(
645 self.config.env(),
646 payload.request_id,
647 payload.binary_security_token,
648 payload.secret,
649 ))
650 }
651
652 pub async fn post_ccsid_for_pcsid(
658 &self,
659 ccsid: &CsidCredentials<Compliance>,
660 ) -> Result<CsidCredentials<Production>, ZatcaError> {
661 self.ensure_env(ccsid)?;
662 let request_id = ccsid
663 .request_id()
664 .ok_or_else(|| ZatcaError::ClientState("Missing compliance request_id".into()))?;
665 let payload = serde_json::json!({
666 "compliance_request_id": request_id,
667 });
668
669 let url = self.build_endpoint("production/csids");
670 let response = self
671 ._client
672 .post(url)
673 .header("Accept", "application/json")
674 .header("Accept-Version", "V2")
675 .header("Content-Type", "application/json")
676 .basic_auth(
677 ccsid.binary_security_token().to_string(),
678 Some(ccsid.secret().to_string()),
679 )
680 .json(&payload)
681 .send()
682 .await
683 .map_err(|e| ZatcaError::Http(e.to_string()))?;
684 let status = response.status();
685
686 if !status.is_success() {
687 let message = response.text().await.unwrap_or_default();
688 return Err(ZatcaError::InvalidResponse(format!(
689 "status {status}: {message}"
690 )));
691 }
692
693 let payload: CsidResponseBody = response
694 .json()
695 .await
696 .map_err(|e| ZatcaError::InvalidResponse(e.to_string()))?;
697
698 Ok(CsidCredentials::new(
699 self.config.env(),
700 payload.request_id,
701 payload.binary_security_token,
702 payload.secret,
703 ))
704 }
705
706 pub async fn renew_csid(
712 &self,
713 pcsid: &CsidCredentials<Production>,
714 csr: &Csr,
715 otp: &str,
716 accept_language: Option<&str>,
717 ) -> Result<CsidCredentials<Production>, ZatcaError> {
718 self.ensure_env(pcsid)?;
719 let encoded_csr = csr
720 .to_pem_base64()
721 .map_err(|e| ZatcaError::InvalidResponse(e.to_string()))?;
722 let csr_payload = serde_json::json!({ "csr": encoded_csr });
723 let url = self.build_endpoint("production/csids");
724 let mut request = self
725 ._client
726 .patch(url)
727 .header("Accept", "application/json")
728 .header("OTP", otp)
729 .header("Accept-Version", "V2")
730 .header("Content-Type", "application/json")
731 .basic_auth(
732 pcsid.binary_security_token().to_string(),
733 Some(pcsid.secret().to_string()),
734 )
735 .json(&csr_payload);
736
737 match accept_language {
738 Some("ar") => request = request.header("accept-language", "ar"),
739 _ => request = request.header("accept-language", "en"),
740 }
741
742 let response = request
743 .send()
744 .await
745 .map_err(|e| ZatcaError::Http(e.to_string()))?;
746 let status = response.status();
747 let body = response.text().await.unwrap_or_default();
748
749 if status.is_success() || status.as_u16() == 428 {
750 let parsed: RenewalResponseBody = serde_json::from_str(&body)
751 .map_err(|e| ZatcaError::InvalidResponse(format!("Invalid response: {e:?}")))?;
752 let payload = match parsed {
753 RenewalResponseBody::Direct(value) => value,
754 RenewalResponseBody::Wrapped { value } => value,
755 };
756 return Ok(CsidCredentials::new(
757 self.config.env(),
758 payload.request_id,
759 payload.binary_security_token,
760 payload.secret,
761 ));
762 }
763
764 if status.as_u16() == 401 {
765 let parsed = serde_json::from_str::<UnauthorizedResponse>(&body).unwrap_or_else(|_| {
766 UnauthorizedResponse {
767 timestamp: None,
768 status: Some(401),
769 error: Some("Unauthorized".into()),
770 message: Some(body.clone()),
771 }
772 });
773 return Err(ZatcaError::Unauthorized(parsed));
774 }
775
776 if status.is_server_error() {
777 let parsed = serde_json::from_str::<ServerErrorResponse>(&body).unwrap_or_else(|_| {
778 ServerErrorResponse {
779 category: None,
780 code: Some("ServerError".into()),
781 message: Some(body.clone()),
782 }
783 });
784 return Err(ZatcaError::ServerError(parsed));
785 }
786
787 Err(ZatcaError::InvalidResponse(format!(
788 "status {status}: {body}"
789 )))
790 }
791}
792
793impl ZatcaClient {
795 fn build_endpoint(&self, path: &str) -> String {
796 format!(
797 "{}{}",
798 self.base_url,
799 path.trim_start_matches('/')
800 )
801 }
802
803 fn ensure_env<T>(&self, creds: &CsidCredentials<T>) -> Result<(), ZatcaError> {
804 if creds.env() != self.config.env() {
805 return Err(ZatcaError::ClientState("CSID environment mismatch".into()));
806 }
807 Ok(())
808 }
809}
810
811#[cfg(test)]
812mod tests {
813 use super::*;
814 use crate::{
815 csr::CsrProperties,
816 invoice::{
817 sign::SignedProperties, xml::ToXml, Address, CountryCode, InvoiceBuilder,
818 InvoiceSubType, InvoiceType, LineItem, Party, SellerRole, VatCategory,
819 },
820 };
821 use base64ct::{Base64, Encoding};
822 use httpmock::{Method::PATCH, Method::POST, MockServer};
823 use std::path::Path;
824
825 #[test]
826 fn deserialize_validation_response_with_info_object() {
827 let payload = r#"{
828 "validationResults": {
829 "infoMessages": {
830 "type": "INFO",
831 "code": "XSD_ZATCA_VALID",
832 "category": "XSD validation",
833 "message": "Complied with UBL 2.1 standards in line with ZATCA specifications",
834 "status": "PASS"
835 }
836 },
837 "warningMessages": [],
838 "errorMessages": [],
839 "status": "PASS",
840 "reportingStatus": "REPORTED",
841 "clearanceStatus": null,
842 "qrSellertStatus": null,
843 "qrBuyertStatus": null
844 }"#;
845
846 let parsed: ValidationResponse = serde_json::from_str(payload).expect("deserialize");
847 match parsed.validation_results.info_messages {
848 MessageList::One(msg) => assert_eq!(msg.code.as_deref(), Some("XSD_ZATCA_VALID")),
849 other => panic!("expected single info message, got {:?}", other),
850 }
851 }
852
853 #[test]
854 fn deserialize_validation_response_with_info_array() {
855 let payload = r#"{
856 "validationResults": {
857 "infoMessages": [
858 {
859 "type": "INFO",
860 "code": "XSD_ZATCA_VALID",
861 "category": "XSD validation",
862 "message": "Complied with UBL 2.1 standards in line with ZATCA specifications",
863 "status": "PASS"
864 }
865 ],
866 "warningMessages": [],
867 "errorMessages": [
868 {
869 "type": "ERROR",
870 "code": "BR-KSA-37",
871 "category": "KSA",
872 "message": "The seller address building number must contain 4 digits.",
873 "status": "ERROR"
874 }
875 ],
876 "status": "ERROR"
877 },
878 "reportingStatus": "NOT_REPORTED",
879 "clearanceStatus": null,
880 "qrSellertStatus": null,
881 "qrBuyertStatus": null
882 }"#;
883
884 let parsed: ValidationResponse = serde_json::from_str(payload).expect("deserialize");
885 match parsed.validation_results.info_messages {
886 MessageList::Many(list) => assert_eq!(list.len(), 1),
887 other => panic!("expected info list, got {:?}", other),
888 }
889 assert_eq!(parsed.validation_results.error_messages.len(), 1);
890 }
891
892 #[test]
893 fn deserialize_renewal_response_wrapped() {
894 let payload = r#"{
895 "value": {
896 "requestID": 1234567890,
897 "tokenType": null,
898 "dispositionMessage": "NOT_COMPLIANT",
899 "binarySecurityToken": "token",
900 "secret": "secret",
901 "errors": null
902 }
903 }"#;
904
905 let parsed: RenewalResponseBody = serde_json::from_str(payload).expect("deserialize");
906 let value = match parsed {
907 RenewalResponseBody::Wrapped { value } => value,
908 RenewalResponseBody::Direct(value) => value,
909 };
910 assert_eq!(value.request_id.as_deref(), Some("1234567890"));
911 assert_eq!(value.binary_security_token, "token");
912 assert_eq!(value.secret, "secret");
913 }
914
915 #[test]
916 fn deserialize_validation_response_defaults_info_messages() {
917 let payload = r#"{
918 "validationResults": {
919 "warningMessages": [],
920 "errorMessages": [],
921 "status": "PASS"
922 },
923 "reportingStatus": "REPORTED",
924 "clearanceStatus": null,
925 "qrSellertStatus": null,
926 "qrBuyertStatus": null
927 }"#;
928
929 let parsed: ValidationResponse = serde_json::from_str(payload).expect("deserialize");
930 assert!(matches!(
931 parsed.validation_results.info_messages,
932 MessageList::Empty
933 ));
934 }
935
936 #[test]
937 fn csid_credentials_new_stores_env() {
938 let creds = CsidCredentials::<Compliance>::new(
939 EnvironmentType::Simulation,
940 Some("10".into()),
941 "token",
942 "secret",
943 );
944 assert_eq!(creds.env(), EnvironmentType::Simulation);
945 assert_eq!(creds.request_id(), Some("10"));
946 }
947
948 #[test]
949 fn response_getters_expose_fields() {
950 let payload = r#"{
951 "validationResults": {
952 "infoMessages": {
953 "type": "INFO",
954 "code": "INFO_CODE",
955 "category": "Info",
956 "message": "ok",
957 "status": "PASS"
958 },
959 "warningMessages": [
960 {
961 "type": "WARN",
962 "code": "WARN_CODE",
963 "category": "Warn",
964 "message": "warn",
965 "status": "WARN"
966 }
967 ],
968 "errorMessages": [],
969 "status": "PASS"
970 },
971 "reportingStatus": "REPORTED",
972 "clearanceStatus": "CLEARED",
973 "qrSellertStatus": "OK",
974 "qrBuyertStatus": "OK"
975 }"#;
976
977 let parsed: ValidationResponse = serde_json::from_str(payload).expect("deserialize");
978 assert_eq!(parsed.reporting_status(), Some("REPORTED"));
979 assert_eq!(parsed.clearance_status(), Some("CLEARED"));
980 assert_eq!(parsed.qr_seller_status(), Some("OK"));
981 assert_eq!(parsed.qr_buyer_status(), Some("OK"));
982
983 let results = parsed.validation_results();
984 assert_eq!(results.status(), Some("PASS"));
985 assert_eq!(results.warning_messages().len(), 1);
986 assert_eq!(results.error_messages().len(), 0);
987
988 match results.info_messages() {
989 MessageList::One(message) => {
990 assert_eq!(message.message_type(), Some("INFO"));
991 assert_eq!(message.code(), Some("INFO_CODE"));
992 assert_eq!(message.category(), Some("Info"));
993 assert_eq!(message.message(), Some("ok"));
994 assert_eq!(message.status(), Some("PASS"));
995 }
996 _ => panic!("expected info message"),
997 }
998 }
999
1000 #[test]
1001 fn error_response_getters_expose_fields() {
1002 let unauthorized = UnauthorizedResponse {
1003 timestamp: Some(1),
1004 status: Some(401),
1005 error: Some("Unauthorized".into()),
1006 message: Some("nope".into()),
1007 };
1008 assert_eq!(unauthorized.timestamp(), Some(1));
1009 assert_eq!(unauthorized.status(), Some(401));
1010 assert_eq!(unauthorized.error(), Some("Unauthorized"));
1011 assert_eq!(unauthorized.message(), Some("nope"));
1012
1013 let server_error = ServerErrorResponse {
1014 category: Some("Server".into()),
1015 code: Some("ERR".into()),
1016 message: Some("boom".into()),
1017 };
1018 assert_eq!(server_error.category(), Some("Server"));
1019 assert_eq!(server_error.code(), Some("ERR"));
1020 assert_eq!(server_error.message(), Some("boom"));
1021 }
1022
1023 #[test]
1024 fn build_endpoint_trims_leading_slash() {
1025 let client = ZatcaClient::new(Config::default()).expect("client");
1026 let with_slash = client.build_endpoint("/invoices/reporting/single");
1027 let without_slash = client.build_endpoint("invoices/reporting/single");
1028 assert_eq!(with_slash, without_slash);
1029 }
1030
1031 #[test]
1032 fn ensure_env_rejects_mismatch() {
1033 let client = ZatcaClient::new(Config::default()).expect("client");
1034 let creds = CsidCredentials::<Compliance>::new(
1035 EnvironmentType::Production,
1036 None,
1037 "token",
1038 "secret",
1039 );
1040 let err = client.ensure_env(&creds).expect_err("env mismatch");
1041 assert!(matches!(err, ZatcaError::ClientState(_)));
1042 }
1043
1044 #[tokio::test]
1045 async fn report_rejects_standard_invoice() {
1046 let signed_invoice = build_signed_invoice(InvoiceType::Tax(InvoiceSubType::Standard));
1047
1048 let creds = CsidCredentials::new(
1049 EnvironmentType::NonProduction,
1050 None,
1051 "token",
1052 "secret",
1053 );
1054 let client = ZatcaClient::new(Config::default()).expect("client");
1055
1056 let result = client
1057 .report_simplified_invoice(&signed_invoice, &creds, false, None)
1058 .await;
1059 assert!(matches!(result, Err(ZatcaError::ClientState(_))));
1060 }
1061
1062 #[tokio::test]
1063 async fn clearance_rejects_simplified_invoice() {
1064 let signed_invoice = build_signed_invoice(InvoiceType::Tax(InvoiceSubType::Simplified));
1065
1066 let creds = CsidCredentials::new(
1067 EnvironmentType::NonProduction,
1068 None,
1069 "token",
1070 "secret",
1071 );
1072 let client = ZatcaClient::new(Config::default()).expect("client");
1073
1074 let result = client
1075 .clear_standard_invoice(&signed_invoice, &creds, true, None)
1076 .await;
1077 assert!(matches!(result, Err(ZatcaError::ClientState(_))));
1078 }
1079
1080 #[tokio::test]
1081 async fn compliance_rejects_env_mismatch() {
1082 let signed_invoice = build_signed_invoice(InvoiceType::Tax(InvoiceSubType::Simplified));
1083 let client = ZatcaClient::new(Config::default()).expect("client");
1084 let creds = CsidCredentials::new(
1085 EnvironmentType::Production,
1086 None,
1087 "token",
1088 "secret",
1089 );
1090
1091 let result = client.check_invoice_compliance(&signed_invoice, &creds).await;
1092 assert!(matches!(result, Err(ZatcaError::ClientState(_))));
1093 }
1094
1095 #[tokio::test]
1096 async fn post_ccsid_requires_request_id() {
1097 let client = ZatcaClient::new(Config::default()).expect("client");
1098 let creds = CsidCredentials::new(
1099 EnvironmentType::NonProduction,
1100 None,
1101 "token",
1102 "secret",
1103 );
1104
1105 let result = client.post_ccsid_for_pcsid(&creds).await;
1106 assert!(matches!(result, Err(ZatcaError::ClientState(_))));
1107 }
1108
1109 use std::sync::{Mutex, OnceLock};
1110
1111 fn base_url_lock() -> &'static Mutex<()> {
1112 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
1113 LOCK.get_or_init(|| Mutex::new(()))
1114 }
1115
1116 struct BaseUrlGuard {
1117 _lock: std::sync::MutexGuard<'static, ()>,
1118 previous: Option<String>,
1119 }
1120
1121 impl BaseUrlGuard {
1122 fn new(url: &str) -> Self {
1123 let lock = base_url_lock().lock().expect("base url lock");
1124 let previous = std::env::var("FATOORA_ZATCA_BASE_URL").ok();
1125 unsafe {
1126 std::env::set_var("FATOORA_ZATCA_BASE_URL", url);
1127 }
1128 Self {
1129 _lock: lock,
1130 previous,
1131 }
1132 }
1133 }
1134
1135 impl Drop for BaseUrlGuard {
1136 fn drop(&mut self) {
1137 match self.previous.as_ref() {
1138 Some(value) => unsafe {
1139 std::env::set_var("FATOORA_ZATCA_BASE_URL", value);
1140 },
1141 None => unsafe {
1142 std::env::remove_var("FATOORA_ZATCA_BASE_URL");
1143 },
1144 }
1145 }
1146 }
1147
1148 fn try_start_server() -> Option<MockServer> {
1149 std::panic::catch_unwind(MockServer::start).ok()
1150 }
1151
1152 #[test]
1153 fn zatca_invoice_endpoints_use_base_url() {
1154 let server = match try_start_server() {
1155 Some(server) => server,
1156 None => return,
1157 };
1158 let _guard = BaseUrlGuard::new(&format!("{}/", server.base_url()));
1159 let body = r#"{
1160 "validationResults": {
1161 "infoMessages": [],
1162 "warningMessages": [],
1163 "errorMessages": [],
1164 "status": "PASS"
1165 },
1166 "reportingStatus": "REPORTED",
1167 "clearanceStatus": null,
1168 "qrSellertStatus": null,
1169 "qrBuyertStatus": null
1170 }"#;
1171
1172 let report_mock = server.mock(|when, then| {
1173 when.method(POST)
1174 .path("/invoices/reporting/single")
1175 .header("accept-language", "ar");
1176 then.status(200)
1177 .header("content-type", "application/json")
1178 .body(body);
1179 });
1180
1181 let clear_mock = server.mock(|when, then| {
1182 when.method(POST)
1183 .path("/invoices/clearance/single")
1184 .header("accept-language", "en");
1185 then.status(200)
1186 .header("content-type", "application/json")
1187 .body(body);
1188 });
1189
1190 let compliance_mock = server.mock(|when, then| {
1191 when.method(POST)
1192 .path("/compliance/invoices")
1193 .header("accept-language", "en");
1194 then.status(200)
1195 .header("content-type", "application/json")
1196 .body(body);
1197 });
1198
1199 let rt = tokio::runtime::Runtime::new().expect("runtime");
1200 rt.block_on(async {
1201 let client = ZatcaClient::new(Config::default()).expect("client");
1202 let simplified = build_signed_invoice(InvoiceType::Tax(InvoiceSubType::Simplified));
1203 let standard = build_signed_invoice(InvoiceType::Tax(InvoiceSubType::Standard));
1204 let pcsid = CsidCredentials::new(
1205 EnvironmentType::NonProduction,
1206 None,
1207 "token",
1208 "secret",
1209 );
1210 let ccsid = CsidCredentials::new(
1211 EnvironmentType::NonProduction,
1212 None,
1213 "token",
1214 "secret",
1215 );
1216
1217 let report = client
1218 .report_simplified_invoice(&simplified, &pcsid, false, Some("ar"))
1219 .await;
1220 assert!(report.is_ok());
1221 let clear = client
1222 .clear_standard_invoice(&standard, &pcsid, true, None)
1223 .await;
1224 assert!(clear.is_ok());
1225 let compliance = client.check_invoice_compliance(&simplified, &ccsid).await;
1226 assert!(compliance.is_ok());
1227
1228 report_mock.assert();
1229 clear_mock.assert();
1230 compliance_mock.assert();
1231 });
1232 }
1233
1234 #[test]
1235 fn zatca_csid_endpoints_use_base_url() {
1236 let server = match try_start_server() {
1237 Some(server) => server,
1238 None => return,
1239 };
1240 let _guard = BaseUrlGuard::new(&format!("{}/", server.base_url()));
1241 let ccsid_body = r#"{
1242 "requestID": 42,
1243 "binarySecurityToken": "token",
1244 "secret": "secret"
1245 }"#;
1246 let pcsid_body = r#"{
1247 "requestID": 77,
1248 "binarySecurityToken": "ptoken",
1249 "secret": "psecret"
1250 }"#;
1251 let renew_body = r#"{
1252 "value": {
1253 "requestID": 88,
1254 "binarySecurityToken": "rtoken",
1255 "secret": "rsecret"
1256 }
1257 }"#;
1258
1259 let csr_mock = server.mock(|when, then| {
1260 when.method(POST).path("/compliance").header("OTP", "123456");
1261 then.status(200)
1262 .header("content-type", "application/json")
1263 .body(ccsid_body);
1264 });
1265 let pcsid_mock = server.mock(|when, then| {
1266 when.method(POST).path("/production/csids");
1267 then.status(200)
1268 .header("content-type", "application/json")
1269 .body(pcsid_body);
1270 });
1271 let renew_mock = server.mock(|when, then| {
1272 when.method(PATCH)
1273 .path("/production/csids")
1274 .header("accept-language", "ar");
1275 then.status(428)
1276 .header("content-type", "application/json")
1277 .body(renew_body);
1278 });
1279
1280 let rt = tokio::runtime::Runtime::new().expect("runtime");
1281 rt.block_on(async {
1282 let client = ZatcaClient::new(Config::default()).expect("client");
1283 let csr = build_csr();
1284 let ccsid = client
1285 .post_csr_for_ccsid(&csr, "123456")
1286 .await
1287 .expect("ccsid");
1288 assert_eq!(ccsid.request_id(), Some("42"));
1289
1290 let pcsid = client
1291 .post_ccsid_for_pcsid(&ccsid)
1292 .await
1293 .expect("pcsid");
1294 assert_eq!(pcsid.request_id(), Some("77"));
1295
1296 let renewed = client
1297 .renew_csid(&pcsid, &csr, "123456", Some("ar"))
1298 .await
1299 .expect("renew");
1300 assert_eq!(renewed.request_id(), Some("88"));
1301
1302 csr_mock.assert();
1303 pcsid_mock.assert();
1304 renew_mock.assert();
1305 });
1306 }
1307
1308 #[test]
1309 fn report_handles_unauthorized_and_not_acceptable() {
1310 let server = match try_start_server() {
1311 Some(server) => server,
1312 None => return,
1313 };
1314 let _guard = BaseUrlGuard::new(&format!("{}/", server.base_url()));
1315 let unauthorized = r#"{"status":401,"error":"Unauthorized","message":"nope"}"#;
1316
1317 let mut unauthorized_mock = server.mock(|when, then| {
1318 when.method(POST).path("/invoices/reporting/single");
1319 then.status(401)
1320 .header("content-type", "application/json")
1321 .body(unauthorized);
1322 });
1323
1324 let not_acceptable_mock = server.mock(|when, then| {
1325 when.method(POST).path("/invoices/reporting/single");
1326 then.status(406).body("nope");
1327 });
1328
1329 let rt = tokio::runtime::Runtime::new().expect("runtime");
1330 rt.block_on(async {
1331 let client = ZatcaClient::new(Config::default()).expect("client");
1332 let invoice = build_signed_invoice(InvoiceType::Tax(InvoiceSubType::Simplified));
1333 let creds = CsidCredentials::new(
1334 EnvironmentType::NonProduction,
1335 None,
1336 "token",
1337 "secret",
1338 );
1339
1340 let result = client
1341 .report_simplified_invoice(&invoice, &creds, false, None)
1342 .await;
1343 assert!(matches!(result, Err(ZatcaError::Unauthorized(_))));
1344
1345 unauthorized_mock.delete();
1346
1347 let result = client
1348 .report_simplified_invoice(&invoice, &creds, false, None)
1349 .await;
1350 assert!(matches!(result, Err(ZatcaError::InvalidResponse(_))));
1351
1352 not_acceptable_mock.assert();
1353 });
1354 }
1355
1356 #[test]
1357 fn report_and_clear_handle_server_error_and_unauthorized() {
1358 let server = match try_start_server() {
1359 Some(server) => server,
1360 None => return,
1361 };
1362 let _guard = BaseUrlGuard::new(&format!("{}/", server.base_url()));
1363
1364 let report_mock = server.mock(|when, then| {
1365 when.method(POST).path("/invoices/reporting/single");
1366 then.status(500)
1367 .header("content-type", "application/json")
1368 .body(r#"{"category":"Server","code":"ERR","message":"boom"}"#);
1369 });
1370
1371 let clear_mock = server.mock(|when, then| {
1372 when.method(POST).path("/invoices/clearance/single");
1373 then.status(401)
1374 .header("content-type", "application/json")
1375 .body(r#"{"status":401,"error":"Unauthorized","message":"nope"}"#);
1376 });
1377
1378 let rt = tokio::runtime::Runtime::new().expect("runtime");
1379 rt.block_on(async {
1380 let client = ZatcaClient::new(Config::default()).expect("client");
1381 let simplified = build_signed_invoice(InvoiceType::Tax(InvoiceSubType::Simplified));
1382 let standard = build_signed_invoice(InvoiceType::Tax(InvoiceSubType::Standard));
1383 let pcsid = CsidCredentials::new(
1384 EnvironmentType::NonProduction,
1385 None,
1386 "token",
1387 "secret",
1388 );
1389
1390 let result = client
1391 .report_simplified_invoice(&simplified, &pcsid, false, None)
1392 .await;
1393 assert!(matches!(result, Err(ZatcaError::ServerError(_))));
1394
1395 let result = client
1396 .clear_standard_invoice(&standard, &pcsid, true, None)
1397 .await;
1398 assert!(matches!(result, Err(ZatcaError::Unauthorized(_))));
1399
1400 report_mock.assert();
1401 clear_mock.assert();
1402 });
1403 }
1404
1405 #[test]
1406 fn clear_and_compliance_error_paths() {
1407 let server = match try_start_server() {
1408 Some(server) => server,
1409 None => return,
1410 };
1411 let _guard = BaseUrlGuard::new(&format!("{}/", server.base_url()));
1412
1413 let clear_mock = server.mock(|when, then| {
1414 when.method(POST).path("/invoices/clearance/single");
1415 then.status(200).body("not json");
1416 });
1417
1418 let compliance_mock = server.mock(|when, then| {
1419 when.method(POST).path("/compliance/invoices");
1420 then.status(500)
1421 .header("content-type", "application/json")
1422 .body(r#"{"category":"Server","code":"ERR","message":"boom"}"#);
1423 });
1424
1425 let rt = tokio::runtime::Runtime::new().expect("runtime");
1426 rt.block_on(async {
1427 let client = ZatcaClient::new(Config::default()).expect("client");
1428 let standard = build_signed_invoice(InvoiceType::Tax(InvoiceSubType::Standard));
1429 let simplified = build_signed_invoice(InvoiceType::Tax(InvoiceSubType::Simplified));
1430 let pcsid = CsidCredentials::new(
1431 EnvironmentType::NonProduction,
1432 None,
1433 "token",
1434 "secret",
1435 );
1436 let ccsid = CsidCredentials::new(
1437 EnvironmentType::NonProduction,
1438 None,
1439 "token",
1440 "secret",
1441 );
1442
1443 let result = client
1444 .clear_standard_invoice(&standard, &pcsid, true, None)
1445 .await;
1446 assert!(matches!(result, Err(ZatcaError::InvalidResponse(_))));
1447
1448 let result = client.check_invoice_compliance(&simplified, &ccsid).await;
1449 assert!(matches!(result, Err(ZatcaError::ServerError(_))));
1450
1451 clear_mock.assert();
1452 compliance_mock.assert();
1453 });
1454 }
1455
1456 #[test]
1457 fn report_handles_conflict_response() {
1458 let server = match try_start_server() {
1459 Some(server) => server,
1460 None => return,
1461 };
1462 let _guard = BaseUrlGuard::new(&format!("{}/", server.base_url()));
1463 let body = r#"{
1464 "validationResults": {
1465 "infoMessages": [],
1466 "warningMessages": [],
1467 "errorMessages": [],
1468 "status": "PASS"
1469 },
1470 "reportingStatus": "REPORTED",
1471 "clearanceStatus": null,
1472 "qrSellertStatus": null,
1473 "qrBuyertStatus": null
1474 }"#;
1475
1476 let report_mock = server.mock(|when, then| {
1477 when.method(POST).path("/invoices/reporting/single");
1478 then.status(409)
1479 .header("content-type", "application/json")
1480 .body(body);
1481 });
1482
1483 let rt = tokio::runtime::Runtime::new().expect("runtime");
1484 rt.block_on(async {
1485 let client = ZatcaClient::new(Config::default()).expect("client");
1486 let invoice = build_signed_invoice(InvoiceType::Tax(InvoiceSubType::Simplified));
1487 let pcsid = CsidCredentials::new(
1488 EnvironmentType::NonProduction,
1489 None,
1490 "token",
1491 "secret",
1492 );
1493
1494 let result = client
1495 .report_simplified_invoice(&invoice, &pcsid, false, None)
1496 .await;
1497 assert!(result.is_ok());
1498
1499 report_mock.assert();
1500 });
1501 }
1502
1503 #[test]
1504 fn clear_handles_server_error() {
1505 let server = match try_start_server() {
1506 Some(server) => server,
1507 None => return,
1508 };
1509 let _guard = BaseUrlGuard::new(&format!("{}/", server.base_url()));
1510
1511 let clear_mock = server.mock(|when, then| {
1512 when.method(POST).path("/invoices/clearance/single");
1513 then.status(500)
1514 .header("content-type", "application/json")
1515 .body(r#"{"category":"Server","code":"ERR","message":"boom"}"#);
1516 });
1517
1518 let rt = tokio::runtime::Runtime::new().expect("runtime");
1519 rt.block_on(async {
1520 let client = ZatcaClient::new(Config::default()).expect("client");
1521 let invoice = build_signed_invoice(InvoiceType::Tax(InvoiceSubType::Standard));
1522 let pcsid = CsidCredentials::new(
1523 EnvironmentType::NonProduction,
1524 None,
1525 "token",
1526 "secret",
1527 );
1528
1529 let result = client
1530 .clear_standard_invoice(&invoice, &pcsid, true, None)
1531 .await;
1532 assert!(matches!(result, Err(ZatcaError::ServerError(_))));
1533
1534 clear_mock.assert();
1535 });
1536 }
1537
1538 #[test]
1539 fn compliance_handles_unauthorized() {
1540 let server = match try_start_server() {
1541 Some(server) => server,
1542 None => return,
1543 };
1544 let _guard = BaseUrlGuard::new(&format!("{}/", server.base_url()));
1545
1546 let compliance_mock = server.mock(|when, then| {
1547 when.method(POST).path("/compliance/invoices");
1548 then.status(401)
1549 .header("content-type", "application/json")
1550 .body(r#"{"status":401,"error":"Unauthorized","message":"nope"}"#);
1551 });
1552
1553 let rt = tokio::runtime::Runtime::new().expect("runtime");
1554 rt.block_on(async {
1555 let client = ZatcaClient::new(Config::default()).expect("client");
1556 let invoice = build_signed_invoice(InvoiceType::Tax(InvoiceSubType::Simplified));
1557 let ccsid = CsidCredentials::new(
1558 EnvironmentType::NonProduction,
1559 None,
1560 "token",
1561 "secret",
1562 );
1563
1564 let result = client.check_invoice_compliance(&invoice, &ccsid).await;
1565 assert!(matches!(result, Err(ZatcaError::Unauthorized(_))));
1566
1567 compliance_mock.assert();
1568 });
1569 }
1570
1571 #[test]
1572 fn csid_requests_handle_invalid_and_unauthorized() {
1573 let server = match try_start_server() {
1574 Some(server) => server,
1575 None => return,
1576 };
1577 let _guard = BaseUrlGuard::new(&format!("{}/", server.base_url()));
1578
1579 let csr_mock = server.mock(|when, then| {
1580 when.method(POST).path("/compliance");
1581 then.status(400).body("bad");
1582 });
1583
1584 let pcsid_mock = server.mock(|when, then| {
1585 when.method(POST).path("/production/csids");
1586 then.status(400).body("bad");
1587 });
1588
1589 let renew_mock = server.mock(|when, then| {
1590 when.method(PATCH).path("/production/csids");
1591 then.status(401)
1592 .header("content-type", "application/json")
1593 .body(r#"{"status":401,"error":"Unauthorized","message":"nope"}"#);
1594 });
1595
1596 let rt = tokio::runtime::Runtime::new().expect("runtime");
1597 rt.block_on(async {
1598 let client = ZatcaClient::new(Config::default()).expect("client");
1599 let csr = build_csr();
1600
1601 let result = client.post_csr_for_ccsid(&csr, "123456").await;
1602 assert!(matches!(result, Err(ZatcaError::InvalidResponse(_))));
1603
1604 let ccsid = CsidCredentials::new(
1605 EnvironmentType::NonProduction,
1606 Some("10".into()),
1607 "token",
1608 "secret",
1609 );
1610 let result = client.post_ccsid_for_pcsid(&ccsid).await;
1611 assert!(matches!(result, Err(ZatcaError::InvalidResponse(_))));
1612
1613 let pcsid = CsidCredentials::new(
1614 EnvironmentType::NonProduction,
1615 Some("11".into()),
1616 "token",
1617 "secret",
1618 );
1619 let result = client
1620 .renew_csid(&pcsid, &csr, "123456", None)
1621 .await;
1622 assert!(matches!(result, Err(ZatcaError::Unauthorized(_))));
1623
1624 csr_mock.assert();
1625 pcsid_mock.assert();
1626 renew_mock.assert();
1627 });
1628 }
1629
1630 #[test]
1631 fn compliance_and_renew_invalid_response_paths() {
1632 let server = match try_start_server() {
1633 Some(server) => server,
1634 None => return,
1635 };
1636 let _guard = BaseUrlGuard::new(&format!("{}/", server.base_url()));
1637
1638 let compliance_mock = server.mock(|when, then| {
1639 when.method(POST).path("/compliance/invoices");
1640 then.status(200).body("not json");
1641 });
1642
1643 let renew_mock = server.mock(|when, then| {
1644 when.method(PATCH).path("/production/csids");
1645 then.status(418).body("nope");
1646 });
1647
1648 let rt = tokio::runtime::Runtime::new().expect("runtime");
1649 rt.block_on(async {
1650 let client = ZatcaClient::new(Config::default()).expect("client");
1651 let invoice = build_signed_invoice(InvoiceType::Tax(InvoiceSubType::Simplified));
1652 let ccsid = CsidCredentials::new(
1653 EnvironmentType::NonProduction,
1654 None,
1655 "token",
1656 "secret",
1657 );
1658 let pcsid = CsidCredentials::new(
1659 EnvironmentType::NonProduction,
1660 Some("1".into()),
1661 "token",
1662 "secret",
1663 );
1664 let csr = build_csr();
1665
1666 let result = client.check_invoice_compliance(&invoice, &ccsid).await;
1667 assert!(matches!(result, Err(ZatcaError::InvalidResponse(_))));
1668
1669 let result = client
1670 .renew_csid(&pcsid, &csr, "123456", None)
1671 .await;
1672 assert!(matches!(result, Err(ZatcaError::InvalidResponse(_))));
1673
1674 compliance_mock.assert();
1675 renew_mock.assert();
1676 });
1677 }
1678
1679 #[test]
1680 fn csid_requests_handle_invalid_json() {
1681 let server = match try_start_server() {
1682 Some(server) => server,
1683 None => return,
1684 };
1685 let _guard = BaseUrlGuard::new(&format!("{}/", server.base_url()));
1686
1687 let csr_mock = server.mock(|when, then| {
1688 when.method(POST).path("/compliance");
1689 then.status(200).body("not json");
1690 });
1691
1692 let pcsid_mock = server.mock(|when, then| {
1693 when.method(POST).path("/production/csids");
1694 then.status(200).body("not json");
1695 });
1696
1697 let rt = tokio::runtime::Runtime::new().expect("runtime");
1698 rt.block_on(async {
1699 let client = ZatcaClient::new(Config::default()).expect("client");
1700 let csr = build_csr();
1701
1702 let result = client.post_csr_for_ccsid(&csr, "123456").await;
1703 assert!(matches!(result, Err(ZatcaError::InvalidResponse(_))));
1704
1705 let ccsid = CsidCredentials::new(
1706 EnvironmentType::NonProduction,
1707 Some("10".into()),
1708 "token",
1709 "secret",
1710 );
1711 let result = client.post_ccsid_for_pcsid(&ccsid).await;
1712 assert!(matches!(result, Err(ZatcaError::InvalidResponse(_))));
1713
1714 csr_mock.assert();
1715 pcsid_mock.assert();
1716 });
1717 }
1718
1719 fn build_csr() -> Csr {
1720 let config_path = Path::new(env!("CARGO_MANIFEST_DIR"))
1721 .join("tests/fixtures/csr-configs/csr-config-example-EN.properties");
1722 let csr_props = std::fs::read_to_string(&config_path).expect("read csr config");
1723 let csr_config = CsrProperties::from_properties_str(&csr_props).expect("csr config");
1724 let key = crate::csr::SigningKey::generate();
1725 csr_config
1726 .build(&key, EnvironmentType::NonProduction)
1727 .expect("csr build")
1728 }
1729
1730 fn build_signed_invoice(invoice_type: InvoiceType) -> SignedInvoice {
1731 let seller = Party::<SellerRole>::new(
1732 "Acme Inc".into(),
1733 Address {
1734 country_code: CountryCode::parse("SAU").expect("country code"),
1735 city: "Riyadh".into(),
1736 street: "King Fahd".into(),
1737 additional_street: None,
1738 building_number: "1234".into(),
1739 additional_number: Some("5678".into()),
1740 postal_code: "12222".into(),
1741 subdivision: None,
1742 district: None,
1743 },
1744 "301121971500003",
1745 None,
1746 )
1747 .expect("seller");
1748
1749 let line_item = LineItem::new("Item", 1.0, "PCE", 100.0, 15.0, VatCategory::Standard);
1750
1751 let mut builder = InvoiceBuilder::new(invoice_type);
1752 builder
1753 .set_id("INV-TEST-1")
1754 .set_uuid("uuid-test-1")
1755 .set_issue_datetime("2024-01-01T12:30:00Z")
1756 .set_currency("SAR")
1757 .set_previous_invoice_hash("hash")
1758 .set_invoice_counter(0)
1759 .set_seller(seller)
1760 .set_payment_means_code("10")
1761 .set_vat_category(VatCategory::Standard)
1762 .add_line_item(line_item);
1763 let invoice = builder.build().expect("build invoice");
1764
1765 let signed_xml = invoice.to_xml().expect("serialize invoice");
1766 let public_key_b64 = Base64::encode_string(b"pk");
1767 let signing =
1768 SignedProperties::from_qr_parts("hash==", "signature==", &public_key_b64, None);
1769 invoice
1770 .sign_with_bundle(signing, signed_xml)
1771 .expect("sign invoice")
1772 }
1773}