Complete Script Generator feature including: Backend: - ScriptCategory, ScriptTemplate, ScriptGeneration models - ScriptTemplateEngine with substitution, filters, sanitization - CRUD + share API endpoints with permission checks - Integration tests for permissions and sharing - Migration 057 with AD User Management seed templates Frontend — Script Library: - Browse templates with category tabs and search - Configure pane with parameter form and script generation - Script preview with live substitution and copy/download - scriptGeneratorStore Zustand store Frontend — Template Editor: - Full CRUD form with metadata, script body (Monaco Editor), parameters - ParameterSchemaBuilder with visual builder + JSON toggle - ScriptManagePage with routing and nav link Frontend — Parameter Detector: - Client-side PowerShell parameter detection engine - Detects script-level param() blocks and variable assignments - Type inference from PS type annotations and value patterns - ParameterDetectorStepper one-by-one review UI with accept/skip Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
115 lines
4.3 KiB
Python
115 lines
4.3 KiB
Python
"""Tests for ScriptTemplateEngine — parameter substitution, sanitization, and filters."""
|
|
import pytest
|
|
from app.services.script_template_engine import ScriptTemplateEngine, ScriptRenderError
|
|
|
|
|
|
@pytest.fixture
|
|
def engine():
|
|
return ScriptTemplateEngine()
|
|
|
|
|
|
# ── Basic substitution ────────────────────────────────────────────────────
|
|
|
|
def test_simple_substitution(engine):
|
|
body = "New-ADUser -Name '{{ first_name }} {{ last_name }}'"
|
|
result = engine.render(body, {"first_name": "John", "last_name": "Smith"})
|
|
assert result == "New-ADUser -Name 'John Smith'"
|
|
|
|
|
|
def test_missing_required_param_raises(engine):
|
|
body = "New-ADUser -Name '{{ first_name }}'"
|
|
with pytest.raises(ScriptRenderError, match="first_name"):
|
|
engine.render(body, {})
|
|
|
|
|
|
def test_extra_params_ignored(engine):
|
|
body = "New-ADUser -Name '{{ first_name }}'"
|
|
result = engine.render(body, {"first_name": "John", "extra": "ignored"})
|
|
assert result == "New-ADUser -Name 'John'"
|
|
|
|
|
|
# ── Security: single-quote injection ─────────────────────────────────────
|
|
|
|
def test_single_quote_in_value_is_escaped(engine):
|
|
body = "Set-ADUser -Name '{{ name }}'"
|
|
result = engine.render(body, {"name": "O'Brien"})
|
|
# Single quotes doubled for PowerShell safety
|
|
assert "O''Brien" in result
|
|
|
|
|
|
def test_backtick_in_value_is_escaped(engine):
|
|
body = "Write-Host '{{ msg }}'"
|
|
result = engine.render(body, {"msg": "hello`world"})
|
|
assert "`" not in result or "``" in result # backtick is escaped
|
|
|
|
|
|
def test_dollar_sign_in_value_is_escaped(engine):
|
|
body = "Write-Host '{{ msg }}'"
|
|
result = engine.render(body, {"msg": "price is $100"})
|
|
# Dollar sign escaped so it doesn't interpolate as a PowerShell variable
|
|
assert "`$100" in result or "'price is $100'" in result
|
|
|
|
|
|
# ── Filters ──────────────────────────────────────────────────────────────
|
|
|
|
def test_as_secure_string_filter(engine):
|
|
body = "$secPwd = {{ password | as_secure_string }}"
|
|
result = engine.render(body, {"password": "MyP@ss123"})
|
|
assert "ConvertTo-SecureString" in result
|
|
assert "MyP@ss123" in result
|
|
assert "-AsPlainText -Force" in result
|
|
|
|
|
|
def test_as_array_filter(engine):
|
|
body = "$groups = @({{ groups | as_array }})"
|
|
result = engine.render(body, {"groups": ["GroupA", "GroupB"]})
|
|
assert "'GroupA','GroupB'" in result
|
|
|
|
|
|
def test_as_array_filter_single_item(engine):
|
|
body = "$groups = @({{ groups | as_array }})"
|
|
result = engine.render(body, {"groups": ["OnlyGroup"]})
|
|
assert "'OnlyGroup'" in result
|
|
|
|
|
|
def test_as_bool_filter_true(engine):
|
|
body = "$force = {{ force_change | as_bool }}"
|
|
result = engine.render(body, {"force_change": True})
|
|
assert "$true" in result
|
|
|
|
|
|
def test_as_bool_filter_false(engine):
|
|
body = "$force = {{ force_change | as_bool }}"
|
|
result = engine.render(body, {"force_change": False})
|
|
assert "$false" in result
|
|
|
|
|
|
# ── Conditional blocks ───────────────────────────────────────────────────
|
|
|
|
def test_if_block_included_when_truthy(engine):
|
|
body = "{% if groups %}\nAdd-Groups\n{% endif %}"
|
|
result = engine.render(body, {"groups": ["GroupA"]})
|
|
assert "Add-Groups" in result
|
|
|
|
|
|
def test_if_block_excluded_when_falsy(engine):
|
|
body = "{% if groups %}\nAdd-Groups\n{% endif %}"
|
|
result = engine.render(body, {"groups": []})
|
|
assert "Add-Groups" not in result
|
|
|
|
|
|
def test_if_block_excluded_when_missing(engine):
|
|
body = "{% if groups %}\nAdd-Groups\n{% endif %}"
|
|
result = engine.render(body, {})
|
|
assert "Add-Groups" not in result
|
|
|
|
|
|
# ── Parameter redaction ──────────────────────────────────────────────────
|
|
|
|
def test_sensitive_params_redacted_in_record(engine):
|
|
params = {"first_name": "John", "password": "Secret123"}
|
|
sensitive_keys = {"password"}
|
|
redacted = engine.redact_sensitive(params, sensitive_keys)
|
|
assert redacted["first_name"] == "John"
|
|
assert redacted["password"] == "[REDACTED]"
|