Skip to main content

api_test/
suite_schema.rs

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    // REST
149    #[serde(default)]
150    pub request: Option<String>,
151
152    // REST flow
153    #[serde(default, rename = "loginRequest")]
154    pub login_request: Option<String>,
155    #[serde(default, rename = "tokenJq")]
156    pub token_jq: Option<String>,
157
158    // GraphQL
159    #[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    // TODO(sprint>6): model cleanup steps once runner implementation lands.
169    #[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}