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