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:
@@ -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")
|
||||
|
||||
114
backend/tests/test_script_template_engine.py
Normal file
114
backend/tests/test_script_template_engine.py
Normal 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]"
|
||||
238
backend/tests/test_script_templates.py
Normal file
238
backend/tests/test_script_templates.py
Normal 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
|
||||
336
backend/tests/test_scripts.py
Normal file
336
backend/tests/test_scripts.py
Normal 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
|
||||
Reference in New Issue
Block a user