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    #[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    // REST
161    #[serde(default)]
162    pub request: Option<String>,
163
164    // REST flow
165    #[serde(default, rename = "loginRequest")]
166    pub login_request: Option<String>,
167    #[serde(default, rename = "tokenJq")]
168    pub token_jq: Option<String>,
169
170    // GraphQL
171    #[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    // TODO(sprint>6): model cleanup steps once runner implementation lands.
181    #[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}