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