feat: Script Generator Phase 1+2 — backend, engine, API, frontend, template editor, parameter detector

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>
This commit was merged in pull request #105.
This commit is contained in:
chihlasm
2026-03-14 20:18:59 -04:00
committed by GitHub
parent 83b13fcd26
commit d4dbf44781
50 changed files with 11916 additions and 11 deletions

View File

@@ -20,7 +20,13 @@ from app.core.config import settings
settings.REQUIRE_INVITE_CODE = False
# Test database URL (separate from production)
TEST_DATABASE_URL = "postgresql+asyncpg://postgres:postgres@localhost:5432/patherly_test"
# Use DATABASE_TEST_URL env var if set (e.g. inside Docker where host is 'db'),
# otherwise fall back to localhost for local development.
import os
TEST_DATABASE_URL = os.environ.get(
"DATABASE_TEST_URL",
"postgresql+asyncpg://postgres:postgres@localhost:5432/patherly_test",
)
@pytest.fixture(scope="session")

View File

@@ -0,0 +1,114 @@
"""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]"

View File

@@ -0,0 +1,238 @@
"""Integration tests for Script Template Editor permissions and share endpoint."""
import pytest
from httpx import AsyncClient
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.models.user import User
from app.models.script_template import ScriptCategory, ScriptTemplate
# ── Helpers ──────────────────────────────────────────────────────────────
async def _create_category(db: AsyncSession) -> ScriptCategory:
"""Seed a script category for tests."""
cat = ScriptCategory(name="Active Directory", slug="active-directory", sort_order=1)
db.add(cat)
await db.commit()
await db.refresh(cat)
return cat
async def _make_owner(db: AsyncSession, user_id: str) -> None:
"""Promote a user to account owner."""
from uuid import UUID as PyUUID
result = await db.execute(select(User).where(User.id == PyUUID(user_id)))
user = result.scalar_one()
user.account_role = "owner"
await db.commit()
async def _register_and_login(client: AsyncClient, email: str, password: str, name: str) -> tuple[dict, str]:
"""Register a user, login, return (user_data, access_token)."""
resp = await client.post("/api/v1/auth/register", json={"email": email, "password": password, "name": name})
assert resp.status_code in (200, 201)
user_data = resp.json()
login_resp = await client.post("/api/v1/auth/login/json", json={"email": email, "password": password})
assert login_resp.status_code == 200
token = login_resp.json()["access_token"]
return user_data, token
TEMPLATE_PAYLOAD = {
"name": "Test Template",
"script_body": "Write-Host '{{ message }}'",
"parameters_schema": {
"parameters": [
{"key": "message", "label": "Message", "type": "text", "required": True, "order": 1}
]
},
"complexity": "beginner",
}
# ── Tests ────────────────────────────────────────────────────────────────
class TestScriptTemplatePermissions:
"""Test that engineers can create/edit their own templates, but not others'."""
@pytest.mark.asyncio
async def test_engineer_can_create_template(self, client, auth_headers, test_db):
cat = await _create_category(test_db)
payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)}
resp = await client.post("/api/v1/scripts/templates", json=payload, headers=auth_headers)
assert resp.status_code == 201
data = resp.json()
assert data["name"] == "Test Template"
assert data["created_by"] is not None
@pytest.mark.asyncio
async def test_engineer_can_edit_own_template(self, client, auth_headers, test_db):
cat = await _create_category(test_db)
payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)}
create_resp = await client.post("/api/v1/scripts/templates", json=payload, headers=auth_headers)
template_id = create_resp.json()["id"]
update_resp = await client.put(
f"/api/v1/scripts/templates/{template_id}",
json={"name": "Updated Template"},
headers=auth_headers,
)
assert update_resp.status_code == 200
assert update_resp.json()["name"] == "Updated Template"
@pytest.mark.asyncio
async def test_engineer_cannot_edit_others_template(self, client, test_db):
cat = await _create_category(test_db)
# Engineer A creates a template
_, token_a = await _register_and_login(client, "engineer_a@example.com", "TestPass123!", "Engineer A")
headers_a = {"Authorization": f"Bearer {token_a}"}
payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)}
create_resp = await client.post("/api/v1/scripts/templates", json=payload, headers=headers_a)
template_id = create_resp.json()["id"]
# Engineer B tries to edit it
_, token_b = await _register_and_login(client, "engineer_b@example.com", "TestPass123!", "Engineer B")
headers_b = {"Authorization": f"Bearer {token_b}"}
update_resp = await client.put(
f"/api/v1/scripts/templates/{template_id}",
json={"name": "Hijacked!"},
headers=headers_b,
)
assert update_resp.status_code == 403
@pytest.mark.asyncio
async def test_engineer_can_delete_own_template(self, client, auth_headers, test_db):
cat = await _create_category(test_db)
payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)}
create_resp = await client.post("/api/v1/scripts/templates", json=payload, headers=auth_headers)
template_id = create_resp.json()["id"]
delete_resp = await client.delete(f"/api/v1/scripts/templates/{template_id}", headers=auth_headers)
assert delete_resp.status_code == 204
@pytest.mark.asyncio
async def test_viewer_cannot_create_template(self, client, test_db):
_, token = await _register_and_login(client, "viewer@example.com", "TestPass123!", "Viewer")
# Downgrade to viewer
result = await test_db.execute(select(User).where(User.email == "viewer@example.com"))
user = result.scalar_one()
user.role = "viewer"
user.account_role = "viewer"
await test_db.commit()
# Re-login to get new token with updated role
login_resp = await client.post("/api/v1/auth/login/json", json={"email": "viewer@example.com", "password": "TestPass123!"})
token = login_resp.json()["access_token"]
headers = {"Authorization": f"Bearer {token}"}
cat = await _create_category(test_db)
payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)}
resp = await client.post("/api/v1/scripts/templates", json=payload, headers=headers)
assert resp.status_code == 403
@pytest.mark.asyncio
async def test_admin_can_edit_others_template(self, client, test_db, admin_auth_headers):
cat = await _create_category(test_db)
# Create template as a regular engineer
_, token = await _register_and_login(client, "eng@example.com", "TestPass123!", "Eng")
headers = {"Authorization": f"Bearer {token}"}
payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)}
create_resp = await client.post("/api/v1/scripts/templates", json=payload, headers=headers)
template_id = create_resp.json()["id"]
# Admin edits it
update_resp = await client.put(
f"/api/v1/scripts/templates/{template_id}",
json={"name": "Admin Updated"},
headers=admin_auth_headers,
)
assert update_resp.status_code == 200
assert update_resp.json()["name"] == "Admin Updated"
@pytest.mark.asyncio
async def test_managed_filter_returns_own_templates(self, client, test_db):
cat = await _create_category(test_db)
# Engineer A creates a template
_, token_a = await _register_and_login(client, "eng_a2@example.com", "TestPass123!", "Eng A")
headers_a = {"Authorization": f"Bearer {token_a}"}
payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)}
await client.post("/api/v1/scripts/templates", json=payload, headers=headers_a)
# Engineer B should not see A's template in managed view
_, token_b = await _register_and_login(client, "eng_b2@example.com", "TestPass123!", "Eng B")
headers_b = {"Authorization": f"Bearer {token_b}"}
resp = await client.get("/api/v1/scripts/templates?managed=true", headers=headers_b)
assert resp.status_code == 200
assert len(resp.json()) == 0
class TestScriptTemplateShare:
"""Test the share/unshare endpoint."""
@pytest.mark.asyncio
async def test_owner_can_share_template(self, client, test_db):
cat = await _create_category(test_db)
# Registration auto-sets account_role="owner", so this user is already an owner
user_data, token = await _register_and_login(client, "eng_share@example.com", "TestPass123!", "Eng")
headers = {"Authorization": f"Bearer {token}"}
payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)}
create_resp = await client.post("/api/v1/scripts/templates", json=payload, headers=headers)
template_id = create_resp.json()["id"]
# Share — owner should be allowed
share_resp = await client.patch(
f"/api/v1/scripts/templates/{template_id}/share?shared=true",
headers=headers,
)
assert share_resp.status_code == 200
@pytest.mark.asyncio
async def test_engineer_cannot_share_template(self, client, test_db):
cat = await _create_category(test_db)
# Create a user and downgrade to engineer (registration sets owner by default)
user_data, token = await _register_and_login(client, "eng_noshare@example.com", "TestPass123!", "Eng NoShare")
result = await test_db.execute(select(User).where(User.email == "eng_noshare@example.com"))
user = result.scalar_one()
user.account_role = "engineer"
await test_db.commit()
# Re-login to get fresh token with updated role
login_resp = await client.post("/api/v1/auth/login/json", json={"email": "eng_noshare@example.com", "password": "TestPass123!"})
eng_token = login_resp.json()["access_token"]
eng_headers = {"Authorization": f"Bearer {eng_token}"}
payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)}
create_resp = await client.post("/api/v1/scripts/templates", json=payload, headers=eng_headers)
template_id = create_resp.json()["id"]
share_resp = await client.patch(
f"/api/v1/scripts/templates/{template_id}/share?shared=true",
headers=eng_headers,
)
assert share_resp.status_code == 403
@pytest.mark.asyncio
async def test_owner_can_unshare_template(self, client, test_db):
cat = await _create_category(test_db)
# Registration auto-sets account_role="owner"
user_data, token = await _register_and_login(client, "eng_unshare@example.com", "TestPass123!", "Eng")
headers = {"Authorization": f"Bearer {token}"}
payload = {**TEMPLATE_PAYLOAD, "category_id": str(cat.id)}
create_resp = await client.post("/api/v1/scripts/templates", json=payload, headers=headers)
template_id = create_resp.json()["id"]
# Share then unshare
await client.patch(f"/api/v1/scripts/templates/{template_id}/share?shared=true", headers=headers)
unshare_resp = await client.patch(
f"/api/v1/scripts/templates/{template_id}/share?shared=false",
headers=headers,
)
assert unshare_resp.status_code == 200
assert unshare_resp.json()["team_id"] is None

View File

@@ -0,0 +1,336 @@
"""Integration tests for Script Generator API endpoints."""
import json
import uuid
from datetime import datetime, timezone
import pytest
import sqlalchemy as sa
# ── Fixtures ──────────────────────────────────────────────────────────────
@pytest.fixture
async def seed_script_data(test_db):
"""Seed script categories and templates into the test database."""
now = datetime.now(timezone.utc)
cat_id = uuid.UUID("00000000-0000-0000-0000-000000000001")
# Insert category
await test_db.execute(
sa.text("""
INSERT INTO script_categories (id, name, slug, description, icon, sort_order, is_active, created_at, updated_at)
VALUES (:id, :name, :slug, :description, :icon, :sort_order, true, :now, :now)
"""),
{
"id": cat_id,
"name": "Active Directory",
"slug": "active-directory",
"description": "User account and group management scripts",
"icon": "shield-check",
"sort_order": 1,
"now": now,
},
)
# Minimal template data for testing
templates = [
{
"id": uuid.UUID("00000000-0000-0000-0001-000000000001"),
"slug": "create-ad-user",
"name": "Create AD User Account",
"description": "Creates a new Active Directory user account.",
"script_body": "$SamAccountName = '{{ sam_account_name }}'",
"parameters_schema": json.dumps({
"parameters": [
{"key": "sam_account_name", "label": "SAM Account Name", "type": "text", "required": True, "order": 1},
]
}),
"complexity": "intermediate",
"estimated_runtime": "< 5 seconds",
"requires_elevation": True,
"tags": json.dumps(["active-directory", "user-management"]),
},
{
"id": uuid.UUID("00000000-0000-0000-0001-000000000002"),
"slug": "disable-ad-user",
"name": "Disable AD User Account",
"description": "Disables an Active Directory user account.",
"script_body": "$SamAccountName = '{{ sam_account_name }}'",
"parameters_schema": json.dumps({
"parameters": [
{"key": "sam_account_name", "label": "SAM Account Name", "type": "text", "required": True, "order": 1},
]
}),
"complexity": "beginner",
"estimated_runtime": "< 5 seconds",
"requires_elevation": True,
"tags": json.dumps(["active-directory", "offboarding"]),
},
{
"id": uuid.UUID("00000000-0000-0000-0001-000000000003"),
"slug": "reset-ad-password",
"name": "Reset AD Password",
"description": "Resets an Active Directory user password.",
"script_body": "$SamAccountName = '{{ sam_account_name }}'\n$NewPassword = {{ new_password | as_secure_string }}\n$ForceChange = {{ force_change_at_logon | as_bool }}\n$UnlockAccount = {{ unlock_account | as_bool }}",
"parameters_schema": json.dumps({
"parameters": [
{"key": "sam_account_name", "label": "SAM Account Name", "type": "text", "required": True, "order": 1},
{"key": "new_password", "label": "New Password", "type": "password", "required": True, "order": 2, "sensitive": True},
{"key": "force_change_at_logon", "label": "Force Change at Next Logon", "type": "boolean", "required": True, "order": 3},
{"key": "unlock_account", "label": "Unlock Account if Locked", "type": "boolean", "required": True, "order": 4},
]
}),
"complexity": "beginner",
"estimated_runtime": "< 5 seconds",
"requires_elevation": True,
"tags": json.dumps(["active-directory", "password"]),
},
{
"id": uuid.UUID("00000000-0000-0000-0001-000000000004"),
"slug": "unlock-ad-account",
"name": "Unlock AD Account",
"description": "Unlocks a locked-out Active Directory user account.",
"script_body": "$SamAccountName = '{{ sam_account_name }}'\n$ShowLockoutInfo = {{ show_lockout_info | as_bool }}",
"parameters_schema": json.dumps({
"parameters": [
{"key": "sam_account_name", "label": "SAM Account Name", "type": "text", "required": True, "order": 1},
{"key": "show_lockout_info", "label": "Show Lockout Source Info", "type": "boolean", "required": False, "order": 2},
]
}),
"complexity": "beginner",
"estimated_runtime": "< 5 seconds",
"requires_elevation": True,
"tags": json.dumps(["active-directory", "lockout"]),
},
{
"id": uuid.UUID("00000000-0000-0000-0001-000000000005"),
"slug": "delete-ad-user",
"name": "Delete AD User Account",
"description": "Permanently deletes an Active Directory user account.",
"script_body": "$SamAccountName = '{{ sam_account_name }}'\n$ConfirmDeletion = {{ confirm_deletion | as_bool }}",
"parameters_schema": json.dumps({
"parameters": [
{"key": "sam_account_name", "label": "SAM Account Name", "type": "text", "required": True, "order": 1},
{"key": "confirm_deletion", "label": "Confirm Deletion", "type": "boolean", "required": True, "order": 2},
]
}),
"complexity": "advanced",
"estimated_runtime": "< 10 seconds",
"requires_elevation": True,
"tags": json.dumps(["active-directory", "destructive"]),
},
{
"id": uuid.UUID("00000000-0000-0000-0001-000000000006"),
"slug": "bulk-user-import",
"name": "Bulk User Import from CSV",
"description": "Imports multiple Active Directory user accounts from a CSV file.",
"script_body": "$CSVPath = '{{ csv_path }}'\n$OUPath = '{{ ou_path }}'",
"parameters_schema": json.dumps({
"parameters": [
{"key": "csv_path", "label": "CSV File Path", "type": "text", "required": True, "order": 1},
{"key": "ou_path", "label": "Target OU", "type": "text", "required": True, "order": 2},
]
}),
"complexity": "advanced",
"estimated_runtime": "1-2 minutes",
"requires_elevation": True,
"tags": json.dumps(["active-directory", "bulk"]),
},
]
for tmpl in templates:
await test_db.execute(
sa.text("""
INSERT INTO script_templates (
id, category_id, name, slug, description,
script_body, parameters_schema, default_values, validation_rules,
tags, complexity, estimated_runtime, requires_elevation,
requires_modules, version, is_verified, is_active, usage_count,
created_at, updated_at
) VALUES (
:id, :category_id, :name, :slug, :description,
:script_body, CAST(:parameters_schema AS jsonb), '{}'::jsonb, '{}'::jsonb,
CAST(:tags AS jsonb), :complexity, :estimated_runtime, :requires_elevation,
'[]'::jsonb, 1, true, true, 0,
:now, :now
)
"""),
{**tmpl, "category_id": cat_id, "now": now},
)
await test_db.commit()
return cat_id
# ── Categories ────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_list_categories_requires_auth(client):
response = await client.get("/api/v1/scripts/categories")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_list_categories_returns_seeded_data(client, auth_headers, seed_script_data):
response = await client.get("/api/v1/scripts/categories", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert isinstance(data, list)
assert any(c["slug"] == "active-directory" for c in data)
# ── Templates ─────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_list_templates_requires_auth(client):
response = await client.get("/api/v1/scripts/templates")
assert response.status_code == 401
@pytest.mark.asyncio
async def test_list_templates_returns_seeded_data(client, auth_headers, seed_script_data):
response = await client.get("/api/v1/scripts/templates", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert len(data) == 6
slugs = [t["slug"] for t in data]
assert "create-ad-user" in slugs
assert "reset-ad-password" in slugs
@pytest.mark.asyncio
async def test_list_templates_filter_by_category(client, auth_headers, seed_script_data):
response = await client.get(
"/api/v1/scripts/templates?category_slug=active-directory",
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert len(data) == 6
@pytest.mark.asyncio
async def test_list_templates_search(client, auth_headers, seed_script_data):
response = await client.get(
"/api/v1/scripts/templates?search=password",
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert any("password" in t["name"].lower() or "password" in t["slug"] for t in data)
@pytest.mark.asyncio
async def test_get_template_detail(client, auth_headers, seed_script_data):
list_resp = await client.get("/api/v1/scripts/templates", headers=auth_headers)
templates = list_resp.json()
template_id = templates[0]["id"]
response = await client.get(f"/api/v1/scripts/templates/{template_id}", headers=auth_headers)
assert response.status_code == 200
data = response.json()
assert "script_body" in data
assert "parameters_schema" in data
@pytest.mark.asyncio
async def test_get_template_detail_not_found(client, auth_headers):
response = await client.get(
"/api/v1/scripts/templates/00000000-0000-0000-0000-000000000099",
headers=auth_headers,
)
assert response.status_code == 404
# ── Generate ──────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_generate_script_success(client, auth_headers, seed_script_data):
list_resp = await client.get(
"/api/v1/scripts/templates?search=unlock",
headers=auth_headers,
)
unlock_template = list_resp.json()[0]
response = await client.post(
"/api/v1/scripts/generate",
json={
"template_id": unlock_template["id"],
"parameters": {"sam_account_name": "jsmith", "show_lockout_info": False},
},
headers=auth_headers,
)
assert response.status_code == 200
data = response.json()
assert "script" in data
assert "jsmith" in data["script"]
assert "id" in data
@pytest.mark.asyncio
async def test_generate_script_missing_required_param(client, auth_headers, seed_script_data):
list_resp = await client.get(
"/api/v1/scripts/templates?search=unlock",
headers=auth_headers,
)
unlock_template = list_resp.json()[0]
response = await client.post(
"/api/v1/scripts/generate",
json={
"template_id": unlock_template["id"],
"parameters": {},
},
headers=auth_headers,
)
assert response.status_code == 422
@pytest.mark.asyncio
async def test_generate_script_password_redacted_in_record(client, auth_headers, seed_script_data):
list_resp = await client.get(
"/api/v1/scripts/templates?search=reset-ad-password",
headers=auth_headers,
)
reset_template = list_resp.json()[0]
await client.post(
"/api/v1/scripts/generate",
json={
"template_id": reset_template["id"],
"parameters": {
"sam_account_name": "jsmith",
"new_password": "SuperSecret123!",
"force_change_at_logon": True,
"unlock_account": True,
},
},
headers=auth_headers,
)
history_resp = await client.get("/api/v1/scripts/generations", headers=auth_headers)
assert history_resp.status_code == 200
generations = history_resp.json()
assert len(generations) > 0
latest = generations[0]
assert latest["parameters_used"].get("new_password") == "[REDACTED]"
# ── Team template CRUD ────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_create_team_template_requires_team_admin(client, auth_headers, seed_script_data):
list_resp = await client.get("/api/v1/scripts/categories", headers=auth_headers)
cat_id = list_resp.json()[0]["id"]
response = await client.post(
"/api/v1/scripts/templates",
json={
"category_id": cat_id,
"name": "My Custom Script",
"script_body": "Write-Host 'hello'",
"parameters_schema": {},
},
headers=auth_headers, # regular engineer
)
assert response.status_code == 403