mirror of
https://github.com/windmill-labs/windmill.git
synced 2026-08-21 00:02:23 +00:00
8a986500b9
* feat: WM_TESTED_RUNNABLE env var + wildcards in test: annotation
Extends the CI test feature so a single test script can cover multiple
runnables and branch on which one triggered it.
- test: annotation now supports glob wildcards: `*` matches one path
segment, `**` matches any depth. A new `ci_test_path_matches` helper
in windmill-common compiles patterns to anchored regexes with a small
quick_cache LRU.
- New migration adds a Postgres GENERATED `has_wildcard` column + partial
index on ci_test_reference so exact-match lookups keep using the
primary index and only wildcard rows are scanned for regex matching.
- ci_test trigger query and the UI `ci_test_results` / `ci_test_results_batch`
endpoints split into exact + wildcard paths; the batch endpoint now
issues one query per distinct kind instead of one per item.
- Worker injects `WM_TESTED_RUNNABLE={kind}/{path}` into CI test jobs,
derived from the trigger metadata stored at push time.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* fix: scope CI test job lookup by trigger + populate WM_TESTED_RUNNABLE in resource interpolation
Scope the ci_test_results LATERAL lookup by v2_job.trigger so multi-target
tests (via wildcards or multiple exact annotations) report the correct job
per target. Also pass the tested runnable through transform_json_value in
resources.rs for consistency with schedule_path.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
* chore: update ee-repo-ref to 489eb0d89702e5d1cc7c6e0f9ba9e0c8e5063741
This commit updates the EE repository reference after PR #546 was merged in windmill-ee-private.
Previous ee-repo-ref: e7534bcafcd8c27fcf870b2ea868e901b00b7960
New ee-repo-ref: 489eb0d89702e5d1cc7c6e0f9ba9e0c8e5063741
Automated by sync-ee-ref workflow.
---------
Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: windmill-internal-app[bot] <windmill-internal-app[bot]@users.noreply.github.com>
819 lines
28 KiB
Rust
819 lines
28 KiB
Rust
use anyhow::anyhow;
|
|
use itertools::Itertools;
|
|
use quick_cache::sync::Cache;
|
|
use regex::Regex;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::{collections::HashMap, str::FromStr, sync::Arc};
|
|
|
|
use serde_json::{value::RawValue, Value};
|
|
|
|
use crate::{error::Error, scripts::ScriptLang};
|
|
|
|
#[derive(PartialEq, Serialize, Deserialize, Debug, Clone)]
|
|
pub enum JsonPrimitiveType {
|
|
String,
|
|
Number,
|
|
Integer,
|
|
Object,
|
|
Array,
|
|
Boolean,
|
|
Null,
|
|
}
|
|
|
|
#[derive(PartialEq, Serialize, Deserialize, Debug, Clone)]
|
|
pub enum SchemaValidationRule {
|
|
StrictEnum(Vec<Value>),
|
|
IsNull,
|
|
IsInteger,
|
|
IsString,
|
|
IsBool,
|
|
IsDatetime,
|
|
IsNumber,
|
|
IsEmail,
|
|
IsObject(Vec<(String, Vec<SchemaValidationRule>)>),
|
|
IsArray(Vec<SchemaValidationRule>),
|
|
IsUnionType(Vec<Vec<SchemaValidationRule>>),
|
|
IsOneOf(HashMap<String, Vec<SchemaValidationRule>>),
|
|
IsBytes,
|
|
}
|
|
|
|
impl SchemaValidationRule {
|
|
fn from_primitive(p: &JsonPrimitiveType, val: &Value) -> Result<Vec<Self>, anyhow::Error> {
|
|
let mut schema_rules = vec![];
|
|
|
|
match p {
|
|
JsonPrimitiveType::String => {
|
|
schema_rules.push(SchemaValidationRule::IsString);
|
|
|
|
if let Some(format) = val.get("format").and_then(|f| f.as_str()) {
|
|
if format == "date" || format == "date-time" {
|
|
schema_rules.push(SchemaValidationRule::IsDatetime);
|
|
}
|
|
|
|
if format == "email" {
|
|
schema_rules.push(SchemaValidationRule::IsEmail);
|
|
}
|
|
}
|
|
|
|
if let Some(encoding) = val.get("contentEncoding").and_then(|e| e.as_str()) {
|
|
if encoding == "base64" {
|
|
schema_rules.push(SchemaValidationRule::IsBytes);
|
|
}
|
|
}
|
|
}
|
|
|
|
JsonPrimitiveType::Number => {
|
|
schema_rules.push(SchemaValidationRule::IsNumber);
|
|
}
|
|
JsonPrimitiveType::Integer => {
|
|
schema_rules.push(SchemaValidationRule::IsInteger);
|
|
}
|
|
JsonPrimitiveType::Object => {
|
|
let mut obj_rules = vec![];
|
|
|
|
if let Some(properties) = val.get("properties") {
|
|
let properties = properties
|
|
.as_object()
|
|
.ok_or(anyhow!("Field properties should be an object"))?;
|
|
|
|
for (key, v) in properties {
|
|
obj_rules.push((key.clone(), SchemaValidationRule::from_value(v)?))
|
|
}
|
|
|
|
schema_rules.push(SchemaValidationRule::IsObject(obj_rules));
|
|
} else if let Some(one_of) = val.get("oneOf") {
|
|
let one_of = one_of
|
|
.as_array()
|
|
.ok_or(anyhow!("`oneOf` needs to be an array"))?;
|
|
let mut rules_map: HashMap<String, Vec<SchemaValidationRule>> = HashMap::new();
|
|
|
|
for variant in one_of {
|
|
let variant_label = variant
|
|
.get("title")
|
|
.ok_or(anyhow!(
|
|
"oneOf variant definition should have a `title` field"
|
|
))?
|
|
.as_str()
|
|
.ok_or(anyhow!(
|
|
"oneOf variant definition `title` field should be a string"
|
|
))?;
|
|
if !rules_map.contains_key(variant_label) {
|
|
rules_map.insert(
|
|
variant_label.to_string(),
|
|
SchemaValidationRule::from_value(variant)?,
|
|
);
|
|
} else {
|
|
return Err(anyhow!(
|
|
"oneOf definition has a duplicate variant `{variant_label}`"
|
|
));
|
|
}
|
|
}
|
|
|
|
schema_rules.push(SchemaValidationRule::IsOneOf(rules_map))
|
|
} else {
|
|
let is_resource = val
|
|
.get("format")
|
|
.and_then(|f| f.as_str())
|
|
.map(|f| f.starts_with("resource"))
|
|
.unwrap_or(false);
|
|
if !is_resource {
|
|
return Err(anyhow!(
|
|
"Object type should have a `properties` or `anyOf` field, or be a resource"
|
|
));
|
|
}
|
|
}
|
|
}
|
|
JsonPrimitiveType::Array => {
|
|
let items = val
|
|
.get("items")
|
|
.ok_or(anyhow!("Array type should have field `items`"))?;
|
|
|
|
let arr_rules = SchemaValidationRule::from_value(items)?;
|
|
|
|
schema_rules.push(SchemaValidationRule::IsArray(arr_rules));
|
|
}
|
|
JsonPrimitiveType::Boolean => {
|
|
schema_rules.push(SchemaValidationRule::IsBool);
|
|
}
|
|
JsonPrimitiveType::Null => {
|
|
schema_rules.push(SchemaValidationRule::IsNull);
|
|
}
|
|
}
|
|
|
|
Ok(schema_rules)
|
|
}
|
|
|
|
fn from_value(val: &Value) -> Result<Vec<Self>, Error> {
|
|
if let Some(any_of) = val.get("anyOf").and_then(|any_of| any_of.as_array()) {
|
|
let mut r = vec![];
|
|
|
|
for variant in any_of {
|
|
r.push(SchemaValidationRule::from_value(variant)?);
|
|
}
|
|
return Ok(vec![SchemaValidationRule::IsUnionType(r)]);
|
|
}
|
|
|
|
let mut schema_rules = vec![];
|
|
|
|
let typ = val.get("type").ok_or(anyhow!("Missing `type` field"))?;
|
|
|
|
if let Some(typ) = typ.as_str() {
|
|
schema_rules.append(&mut SchemaValidationRule::from_primitive(
|
|
&JsonPrimitiveType::from_str(typ)?,
|
|
val,
|
|
)?);
|
|
} else if let Some(typ_arr) = typ.as_array() {
|
|
let typ_arr = typ_arr
|
|
.into_iter()
|
|
.map(|v| {
|
|
SchemaValidationRule::from_primitive(
|
|
&JsonPrimitiveType::from_str(
|
|
v.as_str()
|
|
.ok_or(anyhow!("Expected array of strings for `type` field"))?,
|
|
)?,
|
|
v,
|
|
)
|
|
})
|
|
.collect::<Result<Vec<Vec<SchemaValidationRule>>, anyhow::Error>>()?;
|
|
|
|
schema_rules.push(SchemaValidationRule::IsUnionType(typ_arr));
|
|
} else {
|
|
return Err(anyhow!(
|
|
"Unsupported value for type field, expected string or string array"
|
|
)
|
|
.into());
|
|
}
|
|
|
|
if let Some(enum_variants) = val.get("enum") {
|
|
let variants = enum_variants
|
|
.as_array()
|
|
.ok_or(anyhow!("enum variants are not in an array"))?
|
|
.clone();
|
|
schema_rules.push(SchemaValidationRule::StrictEnum(variants));
|
|
}
|
|
|
|
Ok(schema_rules)
|
|
}
|
|
|
|
fn apply_rule(&self, key: &str, val: &Value, required: bool) -> Result<(), Error> {
|
|
if val.is_null() {
|
|
if !required {
|
|
return Ok(());
|
|
}
|
|
return Err(Error::ArgumentErr(format!("Argument {key} cannot be null")));
|
|
}
|
|
match self {
|
|
SchemaValidationRule::IsNull => {
|
|
if !val.is_null() {
|
|
return Err(Error::ArgumentErr(format!(
|
|
"Argument `{key}` should be null"
|
|
)));
|
|
}
|
|
}
|
|
SchemaValidationRule::StrictEnum(vec) => {
|
|
if !vec.contains(val) {
|
|
let options = vec.iter().map(|s| s.to_string()).join(", ");
|
|
return Err(Error::ArgumentErr(format!(
|
|
"Enum type argument `{key}` expected one of `[{options}]` but received {}",
|
|
val.to_string()
|
|
)));
|
|
}
|
|
}
|
|
SchemaValidationRule::IsNumber => {
|
|
if !val.is_number() {
|
|
return Err(Error::ArgumentErr(format!(
|
|
"Argument `{key}` should be a numeric value"
|
|
)));
|
|
}
|
|
}
|
|
SchemaValidationRule::IsInteger => {
|
|
if !val.is_i64() && !val.is_u64() {
|
|
return Err(Error::ArgumentErr(format!(
|
|
"Argument `{key}` should be an integer"
|
|
)));
|
|
}
|
|
}
|
|
SchemaValidationRule::IsString => {
|
|
if !val.is_string() {
|
|
return Err(Error::ArgumentErr(format!(
|
|
"Argument `{key}` should be a string"
|
|
)));
|
|
}
|
|
}
|
|
SchemaValidationRule::IsBool => {
|
|
if !val.is_boolean() {
|
|
return Err(Error::ArgumentErr(format!(
|
|
"Argument `{key}` should be a boolean"
|
|
)));
|
|
}
|
|
}
|
|
SchemaValidationRule::IsObject(o) => {
|
|
if !val.is_object() {
|
|
return Err(Error::ArgumentErr(format!(
|
|
"Argument `{key}` should be an object"
|
|
)));
|
|
}
|
|
|
|
for (s, rules) in o {
|
|
let v = val
|
|
.get(&s)
|
|
.ok_or(Error::ArgumentErr(format!("Missing field {s} in {key}")))?;
|
|
for r in rules {
|
|
r.apply_rule(&format!("{key}.{s}"), v, true)?;
|
|
}
|
|
}
|
|
}
|
|
SchemaValidationRule::IsArray(vec) => {
|
|
if let Some(arr) = val.as_array() {
|
|
for (i, el) in arr.iter().enumerate() {
|
|
for r in vec {
|
|
r.apply_rule(&format!("{key}[{i}]"), el, true)?;
|
|
}
|
|
}
|
|
} else {
|
|
return Err(Error::ArgumentErr(format!(
|
|
"Argument `{key}` should be an array"
|
|
)));
|
|
}
|
|
}
|
|
// TODO: For better error messages on OneOf, make a dedicated OneOf type that matches the label instead of trying the whole type.
|
|
SchemaValidationRule::IsUnionType(vec) => {
|
|
let mut match_count = 0;
|
|
|
|
let mut errors = String::new();
|
|
for typ in vec {
|
|
if let Some(e) = typ
|
|
.iter()
|
|
.map(|r| r.apply_rule(key, val, true))
|
|
.find_map(Result::err)
|
|
{
|
|
errors.push_str(&format!("- {e}\n"));
|
|
} else {
|
|
match_count += 1;
|
|
}
|
|
}
|
|
|
|
if match_count == 0 {
|
|
return Err(Error::ArgumentErr(format!(
|
|
"Argument `{key}` is not valid, failed matching to one of the expected types. Here is a list of possible errors:\n{errors}"
|
|
)));
|
|
}
|
|
}
|
|
SchemaValidationRule::IsOneOf(vec) => {
|
|
let variant_label = val
|
|
.get("label")
|
|
.ok_or(Error::ArgumentErr(format!(
|
|
"oneOf Variant for argument `{key}` should have a label field"
|
|
)))?
|
|
.as_str()
|
|
.ok_or(Error::ArgumentErr(format!(
|
|
"Argument `{key}` of type oneOf expected the label to be a string"
|
|
)))?;
|
|
|
|
let variant_rules = vec
|
|
.get(variant_label)
|
|
.ok_or_else(|| Error::ArgumentErr(format!(
|
|
"Argument `{key}` of type oneOf expected one of the following variants {}, but received `{variant_label}`", vec.keys().join(", ")
|
|
)))?;
|
|
|
|
for r in variant_rules {
|
|
r.apply_rule(key, val, true).map_err(|e| Error::ArgumentErr(format!("Argument `{key}`: The schema for the selected oneOf variant `{variant_label}` was not respected: {e}")))?;
|
|
}
|
|
}
|
|
// TODO: Implement validation on these
|
|
SchemaValidationRule::IsDatetime => (),
|
|
SchemaValidationRule::IsEmail => (),
|
|
SchemaValidationRule::IsBytes => (),
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
}
|
|
|
|
fn find_annotation(comm_lit: &str, annotation: &str, code: &str) -> bool {
|
|
let a = format!("{comm_lit} {annotation}");
|
|
for l in code.lines() {
|
|
if !l.starts_with(comm_lit) {
|
|
break;
|
|
}
|
|
|
|
if l.trim_end() == a {
|
|
return true;
|
|
}
|
|
}
|
|
|
|
false
|
|
}
|
|
|
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
pub struct CiTestedItem {
|
|
pub path: String,
|
|
pub kind: String,
|
|
}
|
|
|
|
/// Parse a CI test annotation from the top of a script.
|
|
///
|
|
/// Multi-line format:
|
|
/// ```
|
|
/// // test:
|
|
/// // script/u/admin/my_script
|
|
/// // flow/u/admin/my_flow
|
|
/// // resource/u/admin/my_resource
|
|
/// ```
|
|
///
|
|
/// One-line format:
|
|
/// ```
|
|
/// // test: script/u/admin/my_script
|
|
/// ```
|
|
pub fn parse_ci_test_annotation(code: &str, comment_prefix: &str) -> Option<Vec<CiTestedItem>> {
|
|
let test_marker = format!("{comment_prefix} test:");
|
|
let mut lines = code.lines();
|
|
|
|
let first = lines.next()?;
|
|
let first_trimmed = first.trim_end();
|
|
if !first_trimmed.starts_with(&test_marker) {
|
|
return None;
|
|
}
|
|
|
|
let mut items = Vec::new();
|
|
|
|
// Check for one-line format: "// test: script/path"
|
|
let inline = first_trimmed[test_marker.len()..].trim();
|
|
if !inline.is_empty() {
|
|
if let Some(item) = parse_ci_test_item(inline) {
|
|
items.push(item);
|
|
}
|
|
}
|
|
|
|
for line in lines {
|
|
let Some(after_prefix) = line.strip_prefix(comment_prefix) else {
|
|
break;
|
|
};
|
|
let stripped = after_prefix.trim();
|
|
if stripped.is_empty() {
|
|
continue;
|
|
}
|
|
|
|
if let Some(item) = parse_ci_test_item(stripped) {
|
|
items.push(item);
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
|
|
if items.is_empty() {
|
|
None
|
|
} else {
|
|
Some(items)
|
|
}
|
|
}
|
|
|
|
fn parse_ci_test_item(s: &str) -> Option<CiTestedItem> {
|
|
if let Some(path) = s.strip_prefix("script/") {
|
|
Some(CiTestedItem { path: path.trim().to_string(), kind: "script".to_string() })
|
|
} else if let Some(path) = s.strip_prefix("flow/") {
|
|
Some(CiTestedItem { path: path.trim().to_string(), kind: "flow".to_string() })
|
|
} else if let Some(path) = s.strip_prefix("resource/") {
|
|
Some(CiTestedItem { path: path.trim().to_string(), kind: "resource".to_string() })
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
|
|
lazy_static::lazy_static! {
|
|
static ref CI_TEST_PATTERN_CACHE: Cache<String, Arc<Regex>> = Cache::new(256);
|
|
}
|
|
|
|
/// Does `path` match an annotation pattern?
|
|
/// - `**` matches any characters including `/`
|
|
/// - `*` matches any characters within a single path segment (no `/`)
|
|
/// - All other regex metacharacters are treated literally
|
|
pub fn ci_test_path_matches(path: &str, pattern: &str) -> bool {
|
|
// Fast path: literal pattern with no wildcard.
|
|
if !pattern.contains('*') {
|
|
return path == pattern;
|
|
}
|
|
|
|
let regex = if let Some(cached) = CI_TEST_PATTERN_CACHE.get(pattern) {
|
|
cached
|
|
} else {
|
|
let mut re = String::with_capacity(pattern.len() + 4);
|
|
re.push('^');
|
|
let mut chars = pattern.chars().peekable();
|
|
while let Some(c) = chars.next() {
|
|
if c == '*' {
|
|
if chars.peek() == Some(&'*') {
|
|
chars.next();
|
|
re.push_str(".*");
|
|
} else {
|
|
re.push_str("[^/]*");
|
|
}
|
|
} else {
|
|
// regex::escape would reallocate per char; inline the ASCII metachar set.
|
|
if matches!(
|
|
c,
|
|
'.' | '+' | '?' | '(' | ')' | '[' | ']' | '{' | '}' | '|' | '^' | '$' | '\\'
|
|
) {
|
|
re.push('\\');
|
|
}
|
|
re.push(c);
|
|
}
|
|
}
|
|
re.push('$');
|
|
match Regex::new(&re) {
|
|
Ok(r) => {
|
|
let arc = Arc::new(r);
|
|
CI_TEST_PATTERN_CACHE.insert(pattern.to_string(), arc.clone());
|
|
arc
|
|
}
|
|
Err(e) => {
|
|
tracing::warn!(%e, pattern, "invalid ci test pattern, skipping");
|
|
return false;
|
|
}
|
|
}
|
|
};
|
|
|
|
regex.is_match(path)
|
|
}
|
|
|
|
pub fn should_validate_schema(code: &str, lang: &ScriptLang) -> bool {
|
|
let annotation = "schema_validation";
|
|
find_annotation(&lang.as_comment_lit(), annotation, code)
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Debug, Clone)]
|
|
pub struct SchemaValidator {
|
|
pub required: Vec<String>,
|
|
pub rules: Vec<(String, Vec<SchemaValidationRule>)>,
|
|
}
|
|
|
|
impl SchemaValidator {
|
|
pub fn validate(&self, args: &HashMap<String, Box<RawValue>>) -> Result<(), Error> {
|
|
for key in &self.required {
|
|
if !args.contains_key(key) {
|
|
return Err(Error::ArgumentErr(format!("Argument {key} is required")));
|
|
}
|
|
}
|
|
|
|
for (key, rules) in &self.rules {
|
|
if let Some(raw_val) = args.get(key) {
|
|
let parsed_val = Value::from_str(raw_val.get()).map_err(|e| {
|
|
Error::ArgumentErr(format!("Failed to parse `{key}` argument: {e}"))
|
|
})?;
|
|
for rule in rules {
|
|
rule.apply_rule(key, &parsed_val, self.required.contains(key))?;
|
|
}
|
|
}
|
|
}
|
|
|
|
Ok(())
|
|
}
|
|
|
|
pub fn from_schema(schema: &str) -> Result<Self, Error> {
|
|
let schema: Value = serde_json::from_str(schema)?;
|
|
|
|
if let Some(draft_version) = schema.get("$schema") {
|
|
match draft_version.as_str() {
|
|
Some("https://json-schema.org/draft/2020-12/schema") => (),
|
|
_ => return Err(anyhow!("Supplied schema draft version is unsuported").into()),
|
|
}
|
|
} else {
|
|
return Err(anyhow!("No draft version supplied").into());
|
|
}
|
|
|
|
let required: Vec<String> = schema
|
|
.get("required")
|
|
.ok_or(anyhow!("Missing `required` field on schema"))?
|
|
.as_array()
|
|
.ok_or(anyhow!("`required` field should be an array of strings"))?
|
|
.into_iter()
|
|
.map(|v| {
|
|
v.as_str()
|
|
.map(|s| s.to_string())
|
|
.ok_or(anyhow!("required field key is not a string"))
|
|
})
|
|
.collect::<Result<Vec<String>, anyhow::Error>>()?;
|
|
|
|
let properties = schema
|
|
.get("properties")
|
|
.ok_or(anyhow!("Missing `properties` field on schema"))?
|
|
.as_object()
|
|
.ok_or(anyhow!("`properties` field should be an object"))?;
|
|
|
|
let mut rules = vec![];
|
|
|
|
for (key, val) in properties {
|
|
rules.push((
|
|
key.clone(),
|
|
SchemaValidationRule::from_value(val)
|
|
.map_err(|e| anyhow!("Problem making rule for {key}: {e}"))?,
|
|
));
|
|
}
|
|
|
|
Ok(Self { required, rules })
|
|
}
|
|
}
|
|
|
|
impl JsonPrimitiveType {
|
|
fn from_str(typ: &str) -> Result<Self, anyhow::Error> {
|
|
match typ {
|
|
"string" => {
|
|
return Ok(JsonPrimitiveType::String);
|
|
}
|
|
"number" => {
|
|
return Ok(JsonPrimitiveType::Number);
|
|
}
|
|
"integer" => {
|
|
return Ok(JsonPrimitiveType::Integer);
|
|
}
|
|
"object" => {
|
|
return Ok(JsonPrimitiveType::Object);
|
|
}
|
|
"array" => {
|
|
return Ok(JsonPrimitiveType::Array);
|
|
}
|
|
"boolean" => {
|
|
return Ok(JsonPrimitiveType::Boolean);
|
|
}
|
|
"null" => {
|
|
return Ok(JsonPrimitiveType::Null);
|
|
}
|
|
other => return Err(anyhow!("Received unsupported type `{other}`").into()),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use serde_json::json;
|
|
|
|
use super::*;
|
|
|
|
fn value_to_rawvalue_map(
|
|
value: Value,
|
|
) -> Result<HashMap<String, Box<RawValue>>, anyhow::Error> {
|
|
match value {
|
|
Value::Object(map) => {
|
|
let mut result = HashMap::new();
|
|
for (key, val) in map {
|
|
let raw = serde_json::to_string(&val)?; // Serialize the Value to a string
|
|
let raw_value: Box<RawValue> = serde_json::from_str(&raw)?; // Convert string to Box<RawValue>
|
|
result.insert(key, raw_value);
|
|
}
|
|
Ok(result)
|
|
}
|
|
_ => Err(anyhow!("Expected a JSON object")),
|
|
}
|
|
}
|
|
#[test]
|
|
fn test_parse_and_validate_schema() {
|
|
let schema = r#"{
|
|
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
"properties": {
|
|
"a": {
|
|
"contentEncoding": "base64",
|
|
"default": null,
|
|
"description": "",
|
|
"originalType": "bytes",
|
|
"type": "string"
|
|
},
|
|
"b": {
|
|
"default": null,
|
|
"description": "",
|
|
"enum": [
|
|
"my",
|
|
"enum"
|
|
],
|
|
"originalType": "enum",
|
|
"type": "string"
|
|
},
|
|
"e": {
|
|
"default": "inferred type string from default arg",
|
|
"description": "",
|
|
"originalType": "string",
|
|
"type": "string"
|
|
},
|
|
"f": {
|
|
"default": {
|
|
"nested": "object"
|
|
},
|
|
"description": "",
|
|
"properties": {
|
|
"nested": {
|
|
"description": "",
|
|
"type": "string",
|
|
"originalType": "string"
|
|
}
|
|
},
|
|
"type": "object"
|
|
},
|
|
"g": {
|
|
"default": null,
|
|
"description": "",
|
|
"oneOf": [
|
|
{
|
|
"type": "object",
|
|
"title": "Variant 1",
|
|
"properties": {
|
|
"label": {
|
|
"description": "",
|
|
"type": "string",
|
|
"originalType": "enum",
|
|
"enum": [
|
|
"Variant 1"
|
|
]
|
|
},
|
|
"foo": {
|
|
"description": "",
|
|
"type": "string",
|
|
"originalType": "string"
|
|
}
|
|
}
|
|
},
|
|
{
|
|
"type": "object",
|
|
"title": "Variant 2",
|
|
"properties": {
|
|
"label": {
|
|
"description": "",
|
|
"type": "string",
|
|
"originalType": "enum",
|
|
"enum": [
|
|
"Variant 2"
|
|
]
|
|
},
|
|
"bar": {
|
|
"description": "",
|
|
"type": "number"
|
|
}
|
|
}
|
|
}
|
|
],
|
|
"type": "object"
|
|
}
|
|
},
|
|
"required": [
|
|
"a",
|
|
"b",
|
|
"g"
|
|
],
|
|
"type": "object"
|
|
}
|
|
"#;
|
|
|
|
let validator = SchemaValidator::from_schema(schema)
|
|
.expect("Schema couldn't be built from a valid schema");
|
|
|
|
let args = json!(
|
|
{
|
|
"g": {
|
|
"label": "Variant 1",
|
|
"foo": ""
|
|
},
|
|
"f": {
|
|
"nested": "object"
|
|
},
|
|
"e": "inferred type string from default arg",
|
|
"b": "my",
|
|
"a": null
|
|
}
|
|
);
|
|
|
|
validator
|
|
.validate(&value_to_rawvalue_map(args).unwrap())
|
|
.err()
|
|
.expect("Validation should not work for this");
|
|
|
|
let args = json!(
|
|
{
|
|
"g": {
|
|
"label": "Variant 1",
|
|
"foo": ""
|
|
},
|
|
"f": {
|
|
"nested": "object"
|
|
},
|
|
"e": "inferred type string from default arg",
|
|
"b": "not_enum",
|
|
"a": "123"
|
|
}
|
|
);
|
|
|
|
validator
|
|
.validate(&value_to_rawvalue_map(args).unwrap())
|
|
.err()
|
|
.expect("Validation should not work for this");
|
|
|
|
let args = json!(
|
|
{
|
|
"g": {
|
|
"label": "Variant 1",
|
|
"foo": ""
|
|
},
|
|
"f": {
|
|
"nested": "object"
|
|
},
|
|
"e": "inferred type string from default arg",
|
|
"b": "my",
|
|
"a": "123"
|
|
}
|
|
);
|
|
|
|
validator
|
|
.validate(&value_to_rawvalue_map(args).unwrap())
|
|
.expect("Validation should work for this");
|
|
}
|
|
|
|
#[test]
|
|
fn ci_test_path_matches_literal() {
|
|
assert!(ci_test_path_matches("u/user/foo", "u/user/foo"));
|
|
assert!(!ci_test_path_matches("u/user/foo", "u/user/bar"));
|
|
assert!(!ci_test_path_matches("u/user/foo/deep", "u/user/foo"));
|
|
}
|
|
|
|
#[test]
|
|
fn ci_test_path_matches_single_star_stays_in_segment() {
|
|
assert!(ci_test_path_matches("u/user/foo", "u/user/*"));
|
|
assert!(ci_test_path_matches("u/user/bar", "u/user/*"));
|
|
assert!(!ci_test_path_matches("u/user/foo/deep", "u/user/*"));
|
|
assert!(!ci_test_path_matches("u/user", "u/user/*"));
|
|
}
|
|
|
|
#[test]
|
|
fn ci_test_path_matches_double_star_crosses_slashes() {
|
|
assert!(ci_test_path_matches("u/user/foo", "u/user/**"));
|
|
assert!(ci_test_path_matches("u/user/foo/deep", "u/user/**"));
|
|
assert!(ci_test_path_matches("u/user/a/b/c", "u/user/**"));
|
|
assert!(!ci_test_path_matches("u/other/foo", "u/user/**"));
|
|
}
|
|
|
|
#[test]
|
|
fn ci_test_path_matches_escapes_regex_metachars() {
|
|
// `.` is a regex metachar; must be matched literally.
|
|
assert!(ci_test_path_matches("u/user/file.txt", "u/user/file.txt"));
|
|
assert!(!ci_test_path_matches("u/user/fileXtxt", "u/user/file.txt"));
|
|
// `+` / `?` / parens must also match literally.
|
|
assert!(ci_test_path_matches("u/user/a+b", "u/user/a+b"));
|
|
assert!(ci_test_path_matches("u/user/a(b)", "u/user/a(b)"));
|
|
}
|
|
|
|
#[test]
|
|
fn ci_test_path_matches_star_at_boundaries() {
|
|
assert!(ci_test_path_matches("u/user/foo", "*/user/foo"));
|
|
assert!(ci_test_path_matches("u/user/foo", "u/*/foo"));
|
|
assert!(ci_test_path_matches("prefix_suffix", "prefix_*"));
|
|
assert!(ci_test_path_matches("", ""));
|
|
assert!(!ci_test_path_matches("u/user/a/b", "u/user/a"));
|
|
}
|
|
|
|
#[test]
|
|
fn ci_test_path_matches_underscore_is_literal() {
|
|
// Underscore is a SQL LIKE metachar but plain text for regex; must match literally.
|
|
assert!(ci_test_path_matches("u/user/my_script", "u/user/my_script"));
|
|
assert!(!ci_test_path_matches(
|
|
"u/user/myXscript",
|
|
"u/user/my_script"
|
|
));
|
|
}
|
|
}
|