Files
regorus/tests/azure_policy_builtins/mod.rs
Anand Krishnamoorthi 5b60daabd9 feat: add Azure Policy builtins with YAML test suite (#630)
* feat: add Azure Policy builtins with YAML test suite

Implement ARM template functions for Azure Policy evaluation:

Builtins:
- String: indexOf, lastIndexOf, trim, format, split, startsWith, endsWith,
  padLeft, concat, replace, toLower, toUpper, substring, guid, uniqueString
- DateTime: dateTimeAdd, dateTimeFromEpoch, dateTimeToEpoch, addDays
- Collection: intersection, union, take, skip, first, last, min, max,
  range, items, tryGet, tryIndexFromEnd, empty, array, createObject
- Encoding: base64, base64ToString, base64ToJson, uri, uriComponent,
  uriComponentToString, dataUri, dataUriToString
- Numeric: int, float, intDiv, intMod
- Misc: json, join, bool, string, coalesce, if, getParameter, resolveField
- Logic: logicAll, logicAny

Key implementation details:
- Unicode case-insensitive search via ICU4X case folding with single-pass
  fold_with_char_map() for indexOf/lastIndexOf
- .NET composite formatting (System.String.Format) with alignment, standard
  and custom datetime format specifiers, numeric format specifiers
- DateTime round-trip preserves input shape (Z vs +00:00, T vs space,
  fractional seconds) when no explicit output format is supplied
- Zero-cost as_str() helper borrows directly from Value::String(Rc<str>)
- BTreeSet<&Value> in array union avoids redundant cloning

Test suite:
- 53 YAML test files exercising all builtins via direct BUILTINS registry
- Coverage for edge cases: empty inputs, Unicode, fractional seconds,
  invalid alignment, unknown format specifiers, RFC3339 offset shapes

Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com>

* fix: address PR review comments

- Fix percent_encode to only uppercase hex digits, not entire string
- Remove guid/uniqueString (unsupported); delete custom SHA-1 impl
- Replace unwrap_or(0) with proper error in format placeholder parsing
- Hoist CaseMapper into static CaseMapperBorrowed for zero per-call overhead
- Pre-allocate Vec in range() with_capacity
- Update bindings/ffi and bindings/ruby Cargo.lock
- Fix uri_component test expectations for correct case preservation

Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com>

* fix: address second round of PR review comments

- float(): return Undefined when as_f64() fails instead of leaking
  the original non-f64 representation
- createObject(): reject odd number of arguments with an error
  (ARM-template parity)
- format(): error on unknown numeric format specifiers instead of
  silently passing through (matches .NET FormatException behavior)
- format(): cap alignment width at 10,000 to prevent DoS from
  user-controlled format strings like {0,1000000000}
- Add YAML test cases for all new error behaviors

Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com>

* fix: address third round of PR review comments

- percent_decode: reject incomplete % escapes (e.g. "%", "%2") instead
  of treating them as literal characters
- parse_iso8601_duration: reject leftover digits without a unit designator
  at T boundary and end-of-input (e.g. "P1", "P1T2H")
- yaml_to_value: panic on unsupported YAML numeric representations instead
  of silently mapping to Null
- Revert unused src/languages/mod.rs changes (module is defined inline in
  lib.rs)

Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com>

* fix: add missing edge-case tests and fix empty-delimiter panic

- fn_split: return input as single-element array for empty string
  delimiter instead of panicking (Rust's str::split("") panics)
- format: add test for F3 higher precision ({0:F3} + 1.23456 → 1.235)
- format: add test for N2 float with thousands separator
- format: add test for negative index error ({-1})
- split: add test for empty-string delimiter
- uri: add tests for query string and fragment in relative URI
- createObject: add test for non-string (numeric) keys

Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com>

* fix: address fourth round of PR review comments

- Add MAX_VARIADIC_ARGS (64) constant for variadic builtin arity
  instead of registering with 0 (logic_all, logic_any, min, max,
  format, intersection, union, coalesce, createObject); set
  dateTimeAdd to exact arity 3

- Switch indexOf/lastIndexOf to UTF-16 code-unit indices to match
  .NET String.IndexOf semantics (track ch.len_utf16() in
  fold_with_char_map, use encode_utf16().count() for empty-needle
  lastIndexOf)

- Use DateTime::<Utc>::from_timestamp for explicit timezone type

- Remove stale docs/azure-policy/casing.md link from module doc

- Fix misleading comment in want_error test branch (code bails on
  Undefined, not accepts it)

Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com>

---------

Signed-off-by: Anand Krishnamoorthi <anakrish@microsoft.com>
2026-03-25 17:32:39 -05:00

216 lines
7.5 KiB
Rust

// Copyright (c) Microsoft Corporation.
// Licensed under the MIT License.
//! YAML-based test runner for Azure Policy builtins.
//!
//! Each YAML file contains a `builtin` field naming the function under test
//! and a list of `cases`. Every case specifies `args` (positional arguments)
//! and either `want` (expected return value) or `want_undefined` (the builtin
//! should return `Value::Undefined`).
//!
//! Tests call builtins directly via the registry (`BUILTINS` map) instead of
//! going through Rego evaluation. This avoids issues with builtin names that
//! collide with Rego keywords (e.g. `azure.policy.if`).
use anyhow::{bail, Result};
use regorus::unstable::{Source, Span, BUILTINS};
use regorus::Value;
use serde::Deserialize;
use test_generator::test_resources;
// ── YAML schema ───────────────────────────────────────────────────────
#[derive(Debug, Deserialize)]
struct YamlTestFile {
/// Dotted builtin name, e.g. `azure.policy.fn.split`.
builtin: String,
cases: Vec<TestCase>,
}
#[derive(Debug, Deserialize)]
struct TestCase {
/// Short human-readable label.
note: String,
/// Positional arguments fed to the builtin.
args: Vec<serde_yaml::Value>,
/// Expected return value (`null` for JSON null).
want: Option<serde_yaml::Value>,
/// If true, the builtin is expected to return null.
/// (Needed because `want: null` in YAML deserializes as Option::None.)
#[serde(default)]
want_null: bool,
/// If true, the builtin is expected to produce Undefined (no result).
#[serde(default)]
want_undefined: bool,
/// If set, the builtin should return an error containing this substring.
want_error: Option<String>,
/// Skip this case.
#[serde(default)]
skip: bool,
}
// ── Helpers ───────────────────────────────────────────────────────────
/// Convert a serde_yaml::Value to a regorus Value.
fn yaml_to_value(v: &serde_yaml::Value) -> Value {
match v {
serde_yaml::Value::Null => Value::Null,
serde_yaml::Value::Bool(b) => Value::Bool(*b),
serde_yaml::Value::Number(n) => {
if let Some(i) = n.as_i64() {
Value::from(i)
} else if let Some(f) = n.as_f64() {
Value::from(f)
} else {
panic!("unsupported YAML numeric representation: {n:?}")
}
}
serde_yaml::Value::String(s) => Value::String(s.as_str().into()),
serde_yaml::Value::Sequence(items) => {
let vals: Vec<Value> = items.iter().map(yaml_to_value).collect();
Value::from(vals)
}
serde_yaml::Value::Mapping(map) => {
let mut obj = Value::new_object();
{
let m = obj.as_object_mut().unwrap();
for (k, v) in map {
m.insert(yaml_to_value(k), yaml_to_value(v));
}
}
obj
}
serde_yaml::Value::Tagged(t) => yaml_to_value(&t.value),
}
}
/// Create a dummy Span for calling builtins outside of normal evaluation.
fn dummy_span() -> Span {
let source = Source::from_contents("<test>".to_string(), String::new())
.expect("creating dummy source should not fail");
Span {
source,
line: 1,
col: 1,
start: 0,
end: 0,
}
}
// ── Test runner ───────────────────────────────────────────────────────
fn run_yaml_test(path: &str) -> Result<()> {
let content = std::fs::read_to_string(path)?;
let test_file: YamlTestFile = serde_yaml::from_str(&content)?;
let filter = std::env::var("TEST_CASE_FILTER").ok();
// Look up the builtin function once for all cases.
let builtin_entry = BUILTINS.get(test_file.builtin.as_str()).unwrap_or_else(|| {
panic!(
"builtin {:?} not found in BUILTINS registry",
test_file.builtin
)
});
let builtin_fn = builtin_entry.0;
let span = dummy_span();
for case in &test_file.cases {
if case.skip {
continue;
}
if let Some(ref f) = filter {
if !case.note.contains(f.as_str()) {
continue;
}
}
// Convert YAML args to Value args.
let args: Vec<Value> = case.args.iter().map(yaml_to_value).collect();
// Call the builtin directly.
let call_result = builtin_fn(&span, &[], &args, false);
// Check error expectations.
if let Some(ref want_err) = case.want_error {
match call_result {
Err(e) => {
let msg = format!("{e:#}");
assert!(
msg.contains(want_err.as_str()),
"[{builtin} / {note}] expected error containing {want_err:?}, got: {msg}",
builtin = test_file.builtin,
note = case.note,
);
}
Ok(ref v) if matches!(v, Value::Undefined) => {
// `want_error` specifically expects an error message string;
// Undefined is not acceptable here — bail.
bail!(
"[{builtin} / {note}] expected error containing {want_err:?} but got Undefined",
builtin = test_file.builtin,
note = case.note,
);
}
Ok(v) => {
bail!(
"[{builtin} / {note}] expected error containing {want_err:?} but got: {v}",
builtin = test_file.builtin,
note = case.note,
);
}
}
continue;
}
// Not expecting an error — unwrap the result.
let actual = call_result.map_err(|e| {
anyhow::anyhow!(
"[{builtin} / {note}] builtin returned error: {e:#}",
builtin = test_file.builtin,
note = case.note,
)
})?;
if case.want_undefined {
assert!(
actual == Value::Undefined,
"[{builtin} / {note}] expected Undefined but got: {actual}",
builtin = test_file.builtin,
note = case.note,
);
continue;
}
// We expect a concrete result.
let expected = if case.want_null {
Value::Null
} else {
let want = case.want.as_ref().unwrap_or_else(|| {
panic!(
"[{} / {}] test case must have `want`, `want_null`, or `want_undefined`",
test_file.builtin, case.note
)
});
yaml_to_value(want)
};
assert!(
actual == expected,
"[{builtin} / {note}]\n expected: {expected}\n actual: {actual}",
builtin = test_file.builtin,
note = case.note,
);
}
Ok(())
}
// ── Test entry point ──────────────────────────────────────────────────
#[test_resources("tests/azure_policy_builtins/cases/*.yaml")]
fn azure_policy_builtin_yaml(path: &str) {
run_yaml_test(path).unwrap();
}