1use std::fmt;
2
3use serde::Deserialize;
4
5pub const SUITE_SCHEMA_VERSION_V1: u32 = 1;
6
7#[derive(Debug, Clone, PartialEq, Eq)]
8pub struct RawText(pub String);
9
10impl RawText {
11 pub fn trimmed_lower(&self) -> String {
12 self.0.trim().to_ascii_lowercase()
13 }
14}
15
16impl<'de> Deserialize<'de> for RawText {
17 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
18 where
19 D: serde::Deserializer<'de>,
20 {
21 let v = serde_json::Value::deserialize(deserializer)?;
22 let s = match v {
23 serde_json::Value::String(s) => s,
24 serde_json::Value::Number(n) => n.to_string(),
25 serde_json::Value::Bool(b) => b.to_string(),
26 serde_json::Value::Null => String::new(),
27 other => other.to_string(),
28 };
29 Ok(Self(s))
30 }
31}
32
33#[derive(Debug, Clone, PartialEq, Deserialize)]
34pub struct SuiteManifestV1 {
35 pub version: u32,
36 #[serde(default)]
37 pub name: Option<String>,
38 #[serde(default)]
39 pub defaults: Option<SuiteDefaultsV1>,
40 #[serde(default)]
41 pub auth: Option<SuiteAuthV1>,
42 pub cases: Vec<SuiteCaseV1>,
43}
44
45#[derive(Debug, Clone, PartialEq, Deserialize)]
46pub struct SuiteDefaultsV1 {
47 #[serde(default)]
48 pub env: Option<String>,
49 #[serde(default, rename = "noHistory")]
50 pub no_history: Option<RawText>,
51 #[serde(default)]
52 pub rest: Option<SuiteDefaultsRestV1>,
53 #[serde(default)]
54 pub graphql: Option<SuiteDefaultsGraphqlV1>,
55}
56
57#[derive(Debug, Clone, PartialEq, Deserialize)]
58pub struct SuiteDefaultsRestV1 {
59 #[serde(default, rename = "configDir")]
60 pub config_dir: Option<String>,
61 #[serde(default)]
62 pub url: Option<String>,
63 #[serde(default)]
64 pub token: Option<String>,
65}
66
67#[derive(Debug, Clone, PartialEq, Deserialize)]
68pub struct SuiteDefaultsGraphqlV1 {
69 #[serde(default, rename = "configDir")]
70 pub config_dir: Option<String>,
71 #[serde(default)]
72 pub url: Option<String>,
73 #[serde(default)]
74 pub jwt: Option<String>,
75}
76
77#[derive(Debug, Clone, PartialEq, Deserialize)]
78pub struct SuiteAuthV1 {
79 #[serde(default)]
80 pub provider: Option<String>,
81 #[serde(default)]
82 pub required: Option<RawText>,
83 #[serde(default, rename = "secretEnv")]
84 pub secret_env: Option<String>,
85 #[serde(default)]
86 pub rest: Option<SuiteAuthRestV1>,
87 #[serde(default)]
88 pub graphql: Option<SuiteAuthGraphqlV1>,
89}
90
91#[derive(Debug, Clone, PartialEq, Deserialize)]
92pub struct SuiteAuthRestV1 {
93 #[serde(default, rename = "loginRequestTemplate")]
94 pub login_request_template: Option<String>,
95 #[serde(default, rename = "credentialsJq")]
96 pub credentials_jq: Option<String>,
97 #[serde(default, rename = "tokenJq")]
98 pub token_jq: Option<String>,
99 #[serde(default, rename = "configDir")]
100 pub config_dir: Option<String>,
101 #[serde(default)]
102 pub url: Option<String>,
103 #[serde(default)]
104 pub env: Option<String>,
105}
106
107#[derive(Debug, Clone, PartialEq, Deserialize)]
108pub struct SuiteAuthGraphqlV1 {
109 #[serde(default, rename = "loginOp")]
110 pub login_op: Option<String>,
111 #[serde(default, rename = "loginVarsTemplate")]
112 pub login_vars_template: Option<String>,
113 #[serde(default, rename = "credentialsJq")]
114 pub credentials_jq: Option<String>,
115 #[serde(default, rename = "tokenJq")]
116 pub token_jq: Option<String>,
117 #[serde(default, rename = "configDir")]
118 pub config_dir: Option<String>,
119 #[serde(default)]
120 pub url: Option<String>,
121 #[serde(default)]
122 pub env: Option<String>,
123}
124
125#[derive(Debug, Clone, PartialEq, Deserialize)]
126pub struct SuiteCaseV1 {
127 #[serde(default)]
128 pub id: Option<String>,
129 #[serde(default, rename = "type")]
130 pub case_type: Option<String>,
131 #[serde(default)]
132 pub tags: Vec<String>,
133 #[serde(default)]
134 pub env: Option<String>,
135 #[serde(default, rename = "noHistory")]
136 pub no_history: Option<RawText>,
137 #[serde(default, rename = "allowWrite")]
138 pub allow_write: Option<RawText>,
139 #[serde(default, rename = "configDir")]
140 pub config_dir: Option<String>,
141 #[serde(default)]
142 pub url: Option<String>,
143 #[serde(default)]
144 pub token: Option<String>,
145 #[serde(default)]
146 pub jwt: Option<String>,
147
148 #[serde(default)]
150 pub request: Option<String>,
151
152 #[serde(default, rename = "loginRequest")]
154 pub login_request: Option<String>,
155 #[serde(default, rename = "tokenJq")]
156 pub token_jq: Option<String>,
157
158 #[serde(default)]
160 pub op: Option<String>,
161 #[serde(default)]
162 pub vars: Option<String>,
163 #[serde(default, rename = "expect")]
164 pub graphql_expect: Option<SuiteGraphqlExpectV1>,
165 #[serde(default, rename = "allowErrors")]
166 pub allow_errors: Option<RawText>,
167
168 #[serde(default)]
170 pub cleanup: Option<serde_json::Value>,
171}
172
173#[derive(Debug, Clone, PartialEq, Deserialize)]
174pub struct SuiteGraphqlExpectV1 {
175 #[serde(default)]
176 pub jq: Option<String>,
177}
178
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub enum SuiteSchemaValidationError {
181 UnsupportedSuiteVersion { got: u32 },
182
183 InvalidSuiteAuthSecretEnvEmpty,
184 InvalidSuiteAuthSecretEnvNotEnvVarName { value: String },
185 InvalidSuiteAuthRequiredNotBoolean,
186 InvalidSuiteAuthProviderRequiredWhenBothPresent,
187 InvalidSuiteAuthProviderValue { value: String },
188
189 InvalidSuiteAuthRestMissingLoginRequestTemplate,
190 InvalidSuiteAuthRestMissingCredentialsJq,
191
192 InvalidSuiteAuthGraphqlMissingLoginOp,
193 InvalidSuiteAuthGraphqlMissingLoginVarsTemplate,
194 InvalidSuiteAuthGraphqlMissingCredentialsJq,
195
196 CaseMissingId { index: usize },
197 CaseMissingType { id: String },
198
199 RestCaseMissingRequest { id: String },
200 RestFlowCaseMissingLoginRequest { id: String },
201 RestFlowCaseMissingRequest { id: String },
202
203 GraphqlCaseMissingOp { id: String },
204 GraphqlCaseAllowErrorsInvalid { id: String },
205 GraphqlCaseAllowErrorsTrueRequiresExpectJq { id: String },
206
207 UnknownCaseType { id: String, case_type: String },
208}
209
210impl fmt::Display for SuiteSchemaValidationError {
211 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
212 match self {
213 SuiteSchemaValidationError::UnsupportedSuiteVersion { got } => {
214 write!(
215 f,
216 "Unsupported suite version: {got} (expected {SUITE_SCHEMA_VERSION_V1})"
217 )
218 }
219
220 SuiteSchemaValidationError::InvalidSuiteAuthSecretEnvEmpty => {
221 write!(f, "Invalid suite auth block: .auth.secretEnv is empty")
222 }
223 SuiteSchemaValidationError::InvalidSuiteAuthSecretEnvNotEnvVarName { value } => write!(
224 f,
225 "Invalid suite auth block: .auth.secretEnv must be a valid env var name (got: {value})"
226 ),
227 SuiteSchemaValidationError::InvalidSuiteAuthRequiredNotBoolean => {
228 write!(
229 f,
230 "Invalid suite auth block: .auth.required must be boolean"
231 )
232 }
233 SuiteSchemaValidationError::InvalidSuiteAuthProviderRequiredWhenBothPresent => write!(
234 f,
235 "Invalid suite auth block: .auth.provider is required when both .auth.rest and .auth.graphql are present"
236 ),
237 SuiteSchemaValidationError::InvalidSuiteAuthProviderValue { value } => write!(
238 f,
239 "Invalid suite auth block: .auth.provider must be one of: rest, graphql (got: {value})"
240 ),
241
242 SuiteSchemaValidationError::InvalidSuiteAuthRestMissingLoginRequestTemplate => write!(
243 f,
244 "Invalid suite auth.rest block: missing loginRequestTemplate"
245 ),
246 SuiteSchemaValidationError::InvalidSuiteAuthRestMissingCredentialsJq => {
247 write!(f, "Invalid suite auth.rest block: missing credentialsJq")
248 }
249
250 SuiteSchemaValidationError::InvalidSuiteAuthGraphqlMissingLoginOp => {
251 write!(f, "Invalid suite auth.graphql block: missing loginOp")
252 }
253 SuiteSchemaValidationError::InvalidSuiteAuthGraphqlMissingLoginVarsTemplate => write!(
254 f,
255 "Invalid suite auth.graphql block: missing loginVarsTemplate"
256 ),
257 SuiteSchemaValidationError::InvalidSuiteAuthGraphqlMissingCredentialsJq => {
258 write!(f, "Invalid suite auth.graphql block: missing credentialsJq")
259 }
260
261 SuiteSchemaValidationError::CaseMissingId { index } => {
262 write!(f, "Case is missing id at index {index}")
263 }
264 SuiteSchemaValidationError::CaseMissingType { id } => {
265 write!(f, "Case '{id}' is missing type")
266 }
267
268 SuiteSchemaValidationError::RestCaseMissingRequest { id } => {
269 write!(f, "REST case '{id}' is missing request")
270 }
271 SuiteSchemaValidationError::RestFlowCaseMissingLoginRequest { id } => {
272 write!(f, "rest-flow case '{id}' is missing loginRequest")
273 }
274 SuiteSchemaValidationError::RestFlowCaseMissingRequest { id } => {
275 write!(f, "rest-flow case '{id}' is missing request")
276 }
277
278 SuiteSchemaValidationError::GraphqlCaseMissingOp { id } => {
279 write!(f, "GraphQL case '{id}' is missing op")
280 }
281 SuiteSchemaValidationError::GraphqlCaseAllowErrorsInvalid { id } => write!(
282 f,
283 "GraphQL case '{id}' has invalid allowErrors (expected boolean)"
284 ),
285 SuiteSchemaValidationError::GraphqlCaseAllowErrorsTrueRequiresExpectJq { id } => {
286 write!(
287 f,
288 "GraphQL case '{id}' with allowErrors=true must set expect.jq"
289 )
290 }
291
292 SuiteSchemaValidationError::UnknownCaseType { id, case_type } => {
293 write!(f, "Unknown case type '{case_type}' for case '{id}'")
294 }
295 }
296 }
297}
298
299impl std::error::Error for SuiteSchemaValidationError {}
300
301fn is_valid_env_var_name(name: &str) -> bool {
302 let name = name.trim();
303 let mut chars = name.chars();
304 let Some(first) = chars.next() else {
305 return false;
306 };
307 if !(first.is_ascii_alphabetic() || first == '_') {
308 return false;
309 }
310 chars.all(|c| c.is_ascii_alphanumeric() || c == '_')
311}
312
313fn parse_bool_raw(raw: &RawText) -> Option<bool> {
314 match raw.trimmed_lower().as_str() {
315 "true" => Some(true),
316 "false" => Some(false),
317 _ => None,
318 }
319}
320
321fn auth_provider_effective(
322 auth: &SuiteAuthV1,
323) -> Result<Option<String>, SuiteSchemaValidationError> {
324 let provider_raw = auth
325 .provider
326 .as_deref()
327 .unwrap_or_default()
328 .trim()
329 .to_ascii_lowercase();
330
331 if !provider_raw.is_empty() {
332 return Ok(Some(provider_raw));
333 }
334
335 let has_rest = auth.rest.is_some();
336 let has_graphql = auth.graphql.is_some();
337
338 if has_rest && !has_graphql {
339 return Ok(Some("rest".to_string()));
340 }
341 if !has_rest && has_graphql {
342 return Ok(Some("graphql".to_string()));
343 }
344
345 Err(SuiteSchemaValidationError::InvalidSuiteAuthProviderRequiredWhenBothPresent)
346}
347
348impl SuiteManifestV1 {
349 pub fn validate(&self) -> Result<(), SuiteSchemaValidationError> {
350 if self.version != SUITE_SCHEMA_VERSION_V1 {
351 return Err(SuiteSchemaValidationError::UnsupportedSuiteVersion { got: self.version });
352 }
353
354 if let Some(auth) = &self.auth {
355 let secret_env = auth
356 .secret_env
357 .as_deref()
358 .unwrap_or("API_TEST_AUTH_JSON")
359 .trim()
360 .to_string();
361 if secret_env.is_empty() {
362 return Err(SuiteSchemaValidationError::InvalidSuiteAuthSecretEnvEmpty);
363 }
364 if !is_valid_env_var_name(&secret_env) {
365 return Err(
366 SuiteSchemaValidationError::InvalidSuiteAuthSecretEnvNotEnvVarName {
367 value: secret_env,
368 },
369 );
370 }
371
372 if let Some(required) = &auth.required
373 && parse_bool_raw(required).is_none()
374 {
375 return Err(SuiteSchemaValidationError::InvalidSuiteAuthRequiredNotBoolean);
376 }
377
378 let mut provider = auth_provider_effective(auth)?;
379 if let Some(p) = &provider
380 && p == "gql"
381 {
382 provider = Some("graphql".to_string());
383 }
384
385 match provider.as_deref() {
386 None => {}
387 Some("rest") => {
388 let rest = auth.rest.as_ref().ok_or(
389 SuiteSchemaValidationError::InvalidSuiteAuthRestMissingLoginRequestTemplate,
390 )?;
391
392 let login = rest
393 .login_request_template
394 .as_deref()
395 .unwrap_or_default()
396 .trim();
397 if login.is_empty() {
398 return Err(
399 SuiteSchemaValidationError::InvalidSuiteAuthRestMissingLoginRequestTemplate,
400 );
401 }
402 let creds = rest.credentials_jq.as_deref().unwrap_or_default().trim();
403 if creds.is_empty() {
404 return Err(
405 SuiteSchemaValidationError::InvalidSuiteAuthRestMissingCredentialsJq,
406 );
407 }
408 }
409 Some("graphql") => {
410 let gql = auth
411 .graphql
412 .as_ref()
413 .ok_or(SuiteSchemaValidationError::InvalidSuiteAuthGraphqlMissingLoginOp)?;
414
415 let login_op = gql.login_op.as_deref().unwrap_or_default().trim();
416 if login_op.is_empty() {
417 return Err(
418 SuiteSchemaValidationError::InvalidSuiteAuthGraphqlMissingLoginOp,
419 );
420 }
421 let login_vars = gql
422 .login_vars_template
423 .as_deref()
424 .unwrap_or_default()
425 .trim();
426 if login_vars.is_empty() {
427 return Err(
428 SuiteSchemaValidationError::InvalidSuiteAuthGraphqlMissingLoginVarsTemplate,
429 );
430 }
431 let creds = gql.credentials_jq.as_deref().unwrap_or_default().trim();
432 if creds.is_empty() {
433 return Err(
434 SuiteSchemaValidationError::InvalidSuiteAuthGraphqlMissingCredentialsJq,
435 );
436 }
437 }
438 Some(other) => {
439 return Err(SuiteSchemaValidationError::InvalidSuiteAuthProviderValue {
440 value: other.to_string(),
441 });
442 }
443 }
444 }
445
446 for (index, case) in self.cases.iter().enumerate() {
447 let id = case.id.as_deref().unwrap_or_default().trim().to_string();
448 if id.is_empty() {
449 return Err(SuiteSchemaValidationError::CaseMissingId { index });
450 }
451
452 let case_type_raw = case
453 .case_type
454 .as_deref()
455 .unwrap_or_default()
456 .trim()
457 .to_string();
458 let case_type = case_type_raw.to_ascii_lowercase();
459 if case_type.is_empty() {
460 return Err(SuiteSchemaValidationError::CaseMissingType { id });
461 }
462
463 match case_type.as_str() {
464 "rest" => {
465 let request = case.request.as_deref().unwrap_or_default().trim();
466 if request.is_empty() {
467 return Err(SuiteSchemaValidationError::RestCaseMissingRequest { id });
468 }
469 }
470 "rest-flow" | "rest_flow" => {
471 let login = case.login_request.as_deref().unwrap_or_default().trim();
472 if login.is_empty() {
473 return Err(
474 SuiteSchemaValidationError::RestFlowCaseMissingLoginRequest { id },
475 );
476 }
477 let request = case.request.as_deref().unwrap_or_default().trim();
478 if request.is_empty() {
479 return Err(SuiteSchemaValidationError::RestFlowCaseMissingRequest { id });
480 }
481 }
482 "graphql" => {
483 let op = case.op.as_deref().unwrap_or_default().trim();
484 if op.is_empty() {
485 return Err(SuiteSchemaValidationError::GraphqlCaseMissingOp { id });
486 }
487
488 let allow_errors = case.allow_errors.as_ref();
489 let allow_errors_value = match allow_errors {
490 None => false,
491 Some(raw) => match parse_bool_raw(raw) {
492 Some(v) => v,
493 None => {
494 return Err(
495 SuiteSchemaValidationError::GraphqlCaseAllowErrorsInvalid {
496 id,
497 },
498 );
499 }
500 },
501 };
502
503 if allow_errors_value {
504 let expect_jq = case
505 .graphql_expect
506 .as_ref()
507 .and_then(|e| e.jq.as_deref())
508 .unwrap_or_default()
509 .trim();
510 if expect_jq.is_empty() {
511 return Err(
512 SuiteSchemaValidationError::GraphqlCaseAllowErrorsTrueRequiresExpectJq { id },
513 );
514 }
515 }
516 }
517 _ => {
518 return Err(SuiteSchemaValidationError::UnknownCaseType {
519 id,
520 case_type: case_type_raw,
521 });
522 }
523 }
524 }
525
526 Ok(())
527 }
528}
529
530#[cfg(test)]
531mod tests {
532 use super::*;
533 use pretty_assertions::assert_eq;
534
535 fn base_rest_case() -> serde_json::Value {
536 serde_json::json!({
537 "id": "rest.health",
538 "type": "rest",
539 "request": "setup/rest/requests/health.request.json"
540 })
541 }
542
543 fn suite_from(value: serde_json::Value) -> SuiteManifestV1 {
544 serde_json::from_value(value).unwrap()
545 }
546
547 #[test]
548 fn suite_schema_v1_accepts_minimal_valid_suite() {
549 let suite: SuiteManifestV1 = suite_from(serde_json::json!({
550 "version": 1,
551 "name": "smoke",
552 "cases": [
553 { "id": "rest.health", "type": "rest", "request": "setup/rest/requests/health.request.json" },
554 { "id": "graphql.health", "type": "graphql", "op": "setup/graphql/ops/health.graphql" }
555 ]
556 }));
557 suite.validate().unwrap();
558 }
559
560 #[test]
561 fn suite_schema_v1_graphql_allow_errors_true_requires_expect_jq() {
562 let suite: SuiteManifestV1 = suite_from(serde_json::json!({
563 "version": 1,
564 "cases": [
565 { "id": "graphql.bad", "type": "graphql", "op": "x.graphql", "allowErrors": true }
566 ]
567 }));
568 let err = suite.validate().unwrap_err();
569 assert_eq!(
570 err,
571 SuiteSchemaValidationError::GraphqlCaseAllowErrorsTrueRequiresExpectJq {
572 id: "graphql.bad".to_string()
573 }
574 );
575 assert!(err.to_string().contains("graphql.bad"));
576 }
577
578 #[test]
579 fn suite_schema_v1_graphql_allow_errors_must_be_boolean() {
580 let suite: SuiteManifestV1 = suite_from(serde_json::json!({
581 "version": 1,
582 "cases": [
583 { "id": "graphql.bad", "type": "graphql", "op": "x.graphql", "allowErrors": "maybe" }
584 ]
585 }));
586 let err = suite.validate().unwrap_err();
587 assert_eq!(
588 err,
589 SuiteSchemaValidationError::GraphqlCaseAllowErrorsInvalid {
590 id: "graphql.bad".to_string()
591 }
592 );
593 assert!(err.to_string().contains("graphql.bad"));
594 }
595
596 #[test]
597 fn suite_schema_v1_unknown_case_type_includes_case_id() {
598 let suite: SuiteManifestV1 = suite_from(serde_json::json!({
599 "version": 1,
600 "cases": [
601 { "id": "x", "type": "soap" }
602 ]
603 }));
604 let err = suite.validate().unwrap_err();
605 assert!(err.to_string().contains("case 'x'"));
606 assert!(err.to_string().contains("soap"));
607 }
608
609 #[test]
610 fn suite_schema_v1_rest_flow_requires_login_request_and_request() {
611 let suite: SuiteManifestV1 = suite_from(serde_json::json!({
612 "version": 1,
613 "cases": [
614 { "id": "rest.flow", "type": "rest-flow", "request": "x.request.json" }
615 ]
616 }));
617 let err = suite.validate().unwrap_err();
618 assert_eq!(
619 err,
620 SuiteSchemaValidationError::RestFlowCaseMissingLoginRequest {
621 id: "rest.flow".to_string()
622 }
623 );
624 }
625
626 #[test]
627 fn suite_schema_v1_auth_secret_env_must_be_valid_env_var_name() {
628 let suite: SuiteManifestV1 = suite_from(serde_json::json!({
629 "version": 1,
630 "auth": { "secretEnv": "123" },
631 "cases": [
632 { "id": "rest.health", "type": "rest", "request": "x.request.json" }
633 ]
634 }));
635 let err = suite.validate().unwrap_err();
636 assert!(err.to_string().contains(".auth.secretEnv"));
637 }
638
639 #[test]
640 fn raw_text_deserializes_primitives() {
641 let value: RawText = serde_json::from_value(serde_json::json!(123)).unwrap();
642 assert_eq!(value.0, "123");
643 let value: RawText = serde_json::from_value(serde_json::json!(true)).unwrap();
644 assert_eq!(value.0, "true");
645 let value: RawText = serde_json::from_value(serde_json::json!(null)).unwrap();
646 assert_eq!(value.0, "");
647 }
648
649 #[test]
650 fn parse_bool_raw_accepts_true_false_and_rejects_other() {
651 assert_eq!(parse_bool_raw(&RawText("true".to_string())), Some(true));
652 assert_eq!(parse_bool_raw(&RawText("false".to_string())), Some(false));
653 assert_eq!(parse_bool_raw(&RawText("nope".to_string())), None);
654 }
655
656 #[test]
657 fn auth_provider_effective_infers_rest_or_graphql() {
658 let auth = SuiteAuthV1 {
659 provider: None,
660 required: None,
661 secret_env: None,
662 rest: Some(SuiteAuthRestV1 {
663 login_request_template: None,
664 credentials_jq: None,
665 token_jq: None,
666 config_dir: None,
667 url: None,
668 env: None,
669 }),
670 graphql: None,
671 };
672 assert_eq!(
673 auth_provider_effective(&auth).unwrap(),
674 Some("rest".to_string())
675 );
676
677 let auth = SuiteAuthV1 {
678 provider: None,
679 required: None,
680 secret_env: None,
681 rest: None,
682 graphql: Some(SuiteAuthGraphqlV1 {
683 login_op: None,
684 login_vars_template: None,
685 credentials_jq: None,
686 token_jq: None,
687 config_dir: None,
688 url: None,
689 env: None,
690 }),
691 };
692 assert_eq!(
693 auth_provider_effective(&auth).unwrap(),
694 Some("graphql".to_string())
695 );
696 }
697
698 #[test]
699 fn auth_provider_effective_requires_provider_when_both_present() {
700 let auth = SuiteAuthV1 {
701 provider: None,
702 required: None,
703 secret_env: None,
704 rest: Some(SuiteAuthRestV1 {
705 login_request_template: None,
706 credentials_jq: None,
707 token_jq: None,
708 config_dir: None,
709 url: None,
710 env: None,
711 }),
712 graphql: Some(SuiteAuthGraphqlV1 {
713 login_op: None,
714 login_vars_template: None,
715 credentials_jq: None,
716 token_jq: None,
717 config_dir: None,
718 url: None,
719 env: None,
720 }),
721 };
722 let err = auth_provider_effective(&auth).unwrap_err();
723 assert_eq!(
724 err,
725 SuiteSchemaValidationError::InvalidSuiteAuthProviderRequiredWhenBothPresent
726 );
727 }
728
729 #[test]
730 fn suite_schema_rejects_auth_required_not_boolean() {
731 let suite = suite_from(serde_json::json!({
732 "version": 1,
733 "auth": { "required": "maybe" },
734 "cases": [base_rest_case()]
735 }));
736 let err = suite.validate().unwrap_err();
737 assert_eq!(
738 err,
739 SuiteSchemaValidationError::InvalidSuiteAuthRequiredNotBoolean
740 );
741 }
742
743 #[test]
744 fn suite_schema_rejects_empty_auth_secret_env() {
745 let suite = suite_from(serde_json::json!({
746 "version": 1,
747 "auth": { "secretEnv": " " },
748 "cases": [base_rest_case()]
749 }));
750 let err = suite.validate().unwrap_err();
751 assert_eq!(
752 err,
753 SuiteSchemaValidationError::InvalidSuiteAuthSecretEnvEmpty
754 );
755 }
756
757 #[test]
758 fn suite_schema_rejects_auth_provider_when_unknown() {
759 let suite = suite_from(serde_json::json!({
760 "version": 1,
761 "auth": { "provider": "soap" },
762 "cases": [base_rest_case()]
763 }));
764 let err = suite.validate().unwrap_err();
765 assert_eq!(
766 err,
767 SuiteSchemaValidationError::InvalidSuiteAuthProviderValue {
768 value: "soap".to_string()
769 }
770 );
771 }
772
773 #[test]
774 fn suite_schema_rejects_rest_auth_missing_login_request_template() {
775 let suite = suite_from(serde_json::json!({
776 "version": 1,
777 "auth": {
778 "provider": "rest",
779 "rest": { "credentialsJq": ".profiles[$profile]" }
780 },
781 "cases": [base_rest_case()]
782 }));
783 let err = suite.validate().unwrap_err();
784 assert_eq!(
785 err,
786 SuiteSchemaValidationError::InvalidSuiteAuthRestMissingLoginRequestTemplate
787 );
788 }
789
790 #[test]
791 fn suite_schema_rejects_rest_auth_missing_credentials_jq() {
792 let suite = suite_from(serde_json::json!({
793 "version": 1,
794 "auth": {
795 "provider": "rest",
796 "rest": { "loginRequestTemplate": "setup/rest/requests/login.request.json" }
797 },
798 "cases": [base_rest_case()]
799 }));
800 let err = suite.validate().unwrap_err();
801 assert_eq!(
802 err,
803 SuiteSchemaValidationError::InvalidSuiteAuthRestMissingCredentialsJq
804 );
805 }
806
807 #[test]
808 fn suite_schema_rejects_graphql_auth_missing_login_op() {
809 let suite = suite_from(serde_json::json!({
810 "version": 1,
811 "auth": { "provider": "graphql", "graphql": {} },
812 "cases": [base_rest_case()]
813 }));
814 let err = suite.validate().unwrap_err();
815 assert_eq!(
816 err,
817 SuiteSchemaValidationError::InvalidSuiteAuthGraphqlMissingLoginOp
818 );
819 }
820
821 #[test]
822 fn suite_schema_rejects_graphql_auth_missing_login_vars_template() {
823 let suite = suite_from(serde_json::json!({
824 "version": 1,
825 "auth": {
826 "provider": "graphql",
827 "graphql": { "loginOp": "setup/graphql/operations/login.graphql" }
828 },
829 "cases": [base_rest_case()]
830 }));
831 let err = suite.validate().unwrap_err();
832 assert_eq!(
833 err,
834 SuiteSchemaValidationError::InvalidSuiteAuthGraphqlMissingLoginVarsTemplate
835 );
836 }
837
838 #[test]
839 fn suite_schema_rejects_graphql_auth_missing_credentials_jq() {
840 let suite = suite_from(serde_json::json!({
841 "version": 1,
842 "auth": {
843 "provider": "graphql",
844 "graphql": {
845 "loginOp": "setup/graphql/operations/login.graphql",
846 "loginVarsTemplate": "setup/graphql/vars/login.json"
847 }
848 },
849 "cases": [base_rest_case()]
850 }));
851 let err = suite.validate().unwrap_err();
852 assert_eq!(
853 err,
854 SuiteSchemaValidationError::InvalidSuiteAuthGraphqlMissingCredentialsJq
855 );
856 }
857
858 #[test]
859 fn suite_schema_rejects_case_missing_id_and_type() {
860 let suite = suite_from(serde_json::json!({
861 "version": 1,
862 "cases": [ { "type": "rest", "request": "x.request.json" } ]
863 }));
864 let err = suite.validate().unwrap_err();
865 assert_eq!(err, SuiteSchemaValidationError::CaseMissingId { index: 0 });
866 }
867
868 #[test]
869 fn suite_schema_rejects_case_missing_type() {
870 let suite = suite_from(serde_json::json!({
871 "version": 1,
872 "cases": [ { "id": "rest.bad", "request": "x.request.json" } ]
873 }));
874 let err = suite.validate().unwrap_err();
875 assert_eq!(
876 err,
877 SuiteSchemaValidationError::CaseMissingType {
878 id: "rest.bad".to_string()
879 }
880 );
881 }
882
883 #[test]
884 fn suite_schema_rejects_rest_case_missing_request() {
885 let suite = suite_from(serde_json::json!({
886 "version": 1,
887 "cases": [ { "id": "rest.missing", "type": "rest" } ]
888 }));
889 let err = suite.validate().unwrap_err();
890 assert_eq!(
891 err,
892 SuiteSchemaValidationError::RestCaseMissingRequest {
893 id: "rest.missing".to_string()
894 }
895 );
896 }
897
898 #[test]
899 fn suite_schema_rejects_rest_flow_missing_request() {
900 let suite = suite_from(serde_json::json!({
901 "version": 1,
902 "cases": [ { "id": "rest.flow", "type": "rest-flow", "loginRequest": "x.request.json" } ]
903 }));
904 let err = suite.validate().unwrap_err();
905 assert_eq!(
906 err,
907 SuiteSchemaValidationError::RestFlowCaseMissingRequest {
908 id: "rest.flow".to_string()
909 }
910 );
911 }
912
913 #[test]
914 fn suite_schema_rejects_graphql_case_missing_op() {
915 let suite = suite_from(serde_json::json!({
916 "version": 1,
917 "cases": [ { "id": "graphql.missing", "type": "graphql" } ]
918 }));
919 let err = suite.validate().unwrap_err();
920 assert_eq!(
921 err,
922 SuiteSchemaValidationError::GraphqlCaseMissingOp {
923 id: "graphql.missing".to_string()
924 }
925 );
926 }
927
928 #[test]
929 fn suite_schema_error_messages_cover_all_variants() {
930 let cases = vec![
931 SuiteSchemaValidationError::UnsupportedSuiteVersion { got: 2 },
932 SuiteSchemaValidationError::InvalidSuiteAuthSecretEnvEmpty,
933 SuiteSchemaValidationError::InvalidSuiteAuthSecretEnvNotEnvVarName {
934 value: "123".to_string(),
935 },
936 SuiteSchemaValidationError::InvalidSuiteAuthRequiredNotBoolean,
937 SuiteSchemaValidationError::InvalidSuiteAuthProviderRequiredWhenBothPresent,
938 SuiteSchemaValidationError::InvalidSuiteAuthProviderValue {
939 value: "soap".to_string(),
940 },
941 SuiteSchemaValidationError::InvalidSuiteAuthRestMissingLoginRequestTemplate,
942 SuiteSchemaValidationError::InvalidSuiteAuthRestMissingCredentialsJq,
943 SuiteSchemaValidationError::InvalidSuiteAuthGraphqlMissingLoginOp,
944 SuiteSchemaValidationError::InvalidSuiteAuthGraphqlMissingLoginVarsTemplate,
945 SuiteSchemaValidationError::InvalidSuiteAuthGraphqlMissingCredentialsJq,
946 SuiteSchemaValidationError::CaseMissingId { index: 0 },
947 SuiteSchemaValidationError::CaseMissingType {
948 id: "case".to_string(),
949 },
950 SuiteSchemaValidationError::RestCaseMissingRequest {
951 id: "rest".to_string(),
952 },
953 SuiteSchemaValidationError::RestFlowCaseMissingLoginRequest {
954 id: "flow".to_string(),
955 },
956 SuiteSchemaValidationError::RestFlowCaseMissingRequest {
957 id: "flow".to_string(),
958 },
959 SuiteSchemaValidationError::GraphqlCaseMissingOp {
960 id: "gql".to_string(),
961 },
962 SuiteSchemaValidationError::GraphqlCaseAllowErrorsInvalid {
963 id: "gql".to_string(),
964 },
965 SuiteSchemaValidationError::GraphqlCaseAllowErrorsTrueRequiresExpectJq {
966 id: "gql".to_string(),
967 },
968 SuiteSchemaValidationError::UnknownCaseType {
969 id: "case".to_string(),
970 case_type: "soap".to_string(),
971 },
972 ];
973
974 for err in cases {
975 let msg = err.to_string();
976 assert!(!msg.trim().is_empty());
977 }
978 }
979}