"""Tests for the 0.2.0 template.json runtime.""" from __future__ import annotations import base64 import json from pathlib import Path import pytest from cli.core.exceptions import TemplateLoadError from cli.core.library import Library from cli.core.template import Template, normalize_template_slug CHARACTER_SECRET_LENGTH = 40 BASE64_SECRET_BYTES = 12 def _write_template_json(template_dir: Path, manifest: dict, files: dict[str, str]) -> None: template_dir.mkdir(parents=True, exist_ok=True) (template_dir / "template.json").write_text(json.dumps(manifest, indent=2), encoding="utf-8") files_dir = template_dir / "files" for relative_path, content in files.items(): target = files_dir / relative_path target.parent.mkdir(parents=True, exist_ok=True) target.write_text(content, encoding="utf-8") def test_template_json_renders_all_files_and_normalizes_items(tmp_path: Path) -> None: """The new manifest shape should normalize variables[].items and render every file in files/.""" template_dir = tmp_path / "compose" / "demo" _write_template_json( template_dir, { "slug": "demo", "kind": "compose", "metadata": { "name": "Demo", "description": "Demo template", "tags": ["test"], "version": { "name": "v1.0.0", "source_dep_name": "ghcr.io/example/demo", "source_dep_version": "1.0.0", "source_dep_digest": "sha256:deadbeef", "upstream_ref": "release-2026-04-22", "notes": "Pinned to the tested upstream image", }, }, "variables": [ { "name": "general", "title": "General", "items": [ { "name": "container_hostname", "type": "str", "title": "Container hostname", "default": "demo", }, { "name": "database_password", "type": "secret", "title": "Database password", "config": { "autogenerated": { "kind": "characters", "length": CHARACTER_SECRET_LENGTH, "characters": ["A", "B", "1", "2"], }, }, }, { "name": "tls_enabled", "type": "bool", "title": "TLS enabled", "default": True, }, { "name": "service_count", "type": "int", "title": "Service count", "default": 3, "config": { "slider": True, "min": 1, "max": 9, "step": 2, "unit": "nodes", }, }, { "name": "notes", "type": "str", "title": "Notes", "config": { "textarea": True, "placeholder": "Optional notes", }, }, ], } ], }, { "compose.yaml": ( "services:\n" " app:\n" " hostname: << container_hostname >>\n" "<% if tls_enabled %>\n" " labels:\n" ' tls: "true"\n' "<% endif %>\n" ), "README.md": "hostname=<< container_hostname >>\n", }, ) template = Template(template_dir, library_name="default") rendered_files, variable_values = template.render(template.variables) assert set(rendered_files) == {"README.md", "compose.yaml"} assert "hostname: demo" in rendered_files["compose.yaml"] assert 'tls: "true"' in rendered_files["compose.yaml"] assert rendered_files["README.md"] == "hostname=demo\n" assert variable_values["container_hostname"] == "demo" assert len(variable_values["database_password"]) == CHARACTER_SECRET_LENGTH assert set(variable_values["database_password"]) <= {"A", "B", "1", "2"} assert template.variables._variable_map["database_password"].autogenerated is True assert template.variables._variable_map["database_password"].type == "secret" assert template.variables._variable_map["database_password"].config.autogenerated is not None assert template.variables._variable_map["service_count"].config.slider is True assert template.variables._variable_map["service_count"].config.unit == "nodes" assert template.variables._variable_map["notes"].config.textarea is True assert template.variables._variable_map["notes"].config.placeholder == "Optional notes" assert template.variables._variable_map["container_hostname"].description == "Container hostname" assert template.metadata.version.name == "v1.0.0" assert template.metadata.version.source_dep_name == "ghcr.io/example/demo" assert template.metadata.version.source_dep_version == "1.0.0" assert template.metadata.version.source_dep_digest == "sha256:deadbeef" assert template.metadata.version.upstream_ref == "release-2026-04-22" assert template.metadata.version.notes == "Pinned to the tested upstream image" def test_template_json_supports_value_field_and_base64_secret_generation(tmp_path: Path) -> None: """Manifest values should honor value fields and structured base64 secret generation.""" template_dir = tmp_path / "compose" / "advanced" _write_template_json( template_dir, { "slug": "advanced", "kind": "compose", "metadata": { "name": "Advanced", "description": "Advanced template", "version": { "source_dep_name": "ghcr.io/example/advanced", "source_dep_version": "1.0.0", }, }, "variables": [ { "name": "general", "title": "General", "items": [ { "name": "environment", "type": "enum", "title": "Environment", "default": "prod", "value": "stage", "config": {"options": ["prod", "stage", "dev"]}, }, { "name": "api_token", "type": "secret", "title": "API token", "config": { "autogenerated": { "kind": "base64", "bytes": BASE64_SECRET_BYTES, } }, }, ], } ], }, { "config.txt": "env=<< environment >>\n", }, ) template = Template(template_dir, library_name="default") rendered_files, variable_values = template.render(template.variables) assert rendered_files["config.txt"] == "env=stage\n" assert variable_values["environment"] == "stage" assert len(base64.b64decode(variable_values["api_token"])) == BASE64_SECRET_BYTES assert template.metadata.version.name == "" assert template.metadata.version.source_dep_name == "ghcr.io/example/advanced" assert template.metadata.version.source_dep_version == "1.0.0" assert not template.metadata.version def test_template_slug_is_manifest_driven_and_normalized(tmp_path: Path) -> None: """The manifest slug defines the template ID, not the directory name.""" template_dir = tmp_path / "compose" / "portainer" _write_template_json( template_dir, { "slug": "portainer-compose", "kind": "compose", "metadata": { "name": "Portainer", "description": "Portainer template", }, "variables": [], }, { "compose.yaml": "services: {}\n", }, ) template = Template(template_dir, library_name="default") assert normalize_template_slug("portainer-compose", "compose") == "portainer" assert template.id == "portainer" assert template.original_id == "portainer" assert template.directory_id == "portainer" assert template.slug == "portainer" assert template.metadata.version.name == "" def test_legacy_template_yaml_is_rejected(tmp_path: Path) -> None: """Legacy template.yaml manifests should be marked incompatible.""" template_dir = tmp_path / "compose" / "legacy" template_dir.mkdir(parents=True, exist_ok=True) (template_dir / "template.yaml").write_text("kind: compose\nmetadata:\n name: Legacy\n", encoding="utf-8") with pytest.raises(TemplateLoadError, match="Legacy template manifests are incompatible"): Template(template_dir, library_name="default") def test_raw_jinja_delimiters_are_allowed_in_rendered_files(tmp_path: Path) -> None: """Rendered files may contain raw Jinja syntax for downstream tools like Ansible.""" template_dir = tmp_path / "ansible" / "raw-jinja" _write_template_json( template_dir, { "slug": "raw-jinja", "kind": "ansible", "metadata": { "name": "Raw Jinja", "description": "Ansible template", "version": { "name": "v1.0.0", }, }, "variables": [ { "name": "general", "title": "General", "items": [ { "name": "target_host", "type": "str", "title": "Target host", "default": "all", } ], } ], }, { "playbook.yaml": ( "- hosts: << target_host >>\n" " tasks:\n" " - debug:\n" ' msg: "{{ ansible_hostname }}"\n' " - name: Raw Ansible block delimiter remains literal\n" " debug:\n" ' msg: "{% raw %}{{ value }}{% endraw %}"\n' ), }, ) template = Template(template_dir, library_name="default") rendered_files, _ = template.render(template.variables) assert template.used_variables == {"target_host"} assert "{{ ansible_hostname }}" in rendered_files["playbook.yaml"] assert "{% raw %}{{ value }}{% endraw %}" in rendered_files["playbook.yaml"] assert "- hosts: all" in rendered_files["playbook.yaml"] def test_template_json_rejects_string_metadata_version(tmp_path: Path) -> None: """metadata.version must use the structured object shape.""" template_dir = tmp_path / "compose" / "bad-version" _write_template_json( template_dir, { "slug": "bad-version", "kind": "compose", "metadata": { "name": "Bad Version", "description": "Bad version metadata", "version": "1.0.0", }, "variables": [], }, { "compose.yaml": "services: {}\n", }, ) with pytest.raises(TemplateLoadError, match=r"'metadata\.version' must be an object"): Template(template_dir, library_name="default") def test_library_discovery_ignores_legacy_template_manifests(tmp_path: Path) -> None: """Only template.json manifests should be treated as templates during discovery.""" module_dir = tmp_path / "compose" legacy_dir = module_dir / "legacy" current_dir = module_dir / "current" legacy_dir.mkdir(parents=True, exist_ok=True) (legacy_dir / "template.yaml").write_text("kind: compose\nmetadata:\n name: Legacy\n", encoding="utf-8") _write_template_json( current_dir, { "slug": "current", "kind": "compose", "metadata": { "name": "Current", "description": "Current template", }, "variables": [], }, { "compose.yaml": "services: {}\n", }, ) library = Library(name="default", path=tmp_path) assert library.find("compose", sort_results=True) == [(current_dir, "default")]