mirror of https://github.com/razor-ai/soup.git
141 lines
5.6 KiB
Python
141 lines
5.6 KiB
Python
"""Tests for v0.39.0 Part E — template registry YAML migration."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import json
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
|
|
class TestTemplateRegistry:
|
|
def test_list_templates_includes_core_set(self):
|
|
from soup_cli.templates import list_templates
|
|
names = list_templates()
|
|
for required in (
|
|
"chat", "code", "reasoning", "vision", "kto", "orpo",
|
|
"simpo", "ipo", "pretrain", "moe", "longcontext", "embedding",
|
|
"audio", "tool-calling", "rlhf", "medical",
|
|
):
|
|
assert required in names, f"{required} missing from {names}"
|
|
|
|
def test_list_templates_returns_sorted(self):
|
|
from soup_cli.templates import list_templates
|
|
names = list_templates()
|
|
assert names == sorted(names)
|
|
|
|
def test_load_template_chat_from_yaml(self):
|
|
from soup_cli.templates import load_template
|
|
body = load_template("chat")
|
|
assert body is not None
|
|
assert "Soup template: Chat Assistant" in body
|
|
assert "task: sft" in body
|
|
|
|
def test_load_template_unknown_returns_none(self):
|
|
from soup_cli.templates import load_template
|
|
assert load_template("does-not-exist") is None
|
|
|
|
def test_load_template_rejects_path_traversal(self):
|
|
from soup_cli.templates import load_template
|
|
with pytest.raises(ValueError):
|
|
load_template("../../etc/passwd")
|
|
with pytest.raises(ValueError):
|
|
load_template("foo/bar")
|
|
with pytest.raises(ValueError):
|
|
load_template("foo\\bar")
|
|
|
|
def test_load_template_rejects_null_byte(self):
|
|
from soup_cli.templates import load_template
|
|
with pytest.raises(ValueError):
|
|
load_template("chat\x00malicious")
|
|
|
|
def test_load_template_rejects_empty_name(self):
|
|
from soup_cli.templates import load_template
|
|
with pytest.raises(ValueError):
|
|
load_template("")
|
|
|
|
def test_yaml_files_exist_for_all_inline(self):
|
|
from soup_cli.config.schema import TEMPLATES
|
|
templates_dir = Path(__file__).resolve().parent.parent / "soup_cli" / "templates"
|
|
for name in TEMPLATES:
|
|
yaml_path = templates_dir / f"{name}.yaml"
|
|
assert yaml_path.is_file(), f"Missing YAML for inline template {name}"
|
|
|
|
def test_manifest_well_formed(self):
|
|
templates_dir = Path(__file__).resolve().parent.parent / "soup_cli" / "templates"
|
|
manifest_path = templates_dir / "manifest.json"
|
|
assert manifest_path.is_file()
|
|
with manifest_path.open() as f:
|
|
data = json.load(f)
|
|
assert "templates" in data
|
|
assert "version" in data
|
|
assert isinstance(data["templates"], dict)
|
|
|
|
def test_yaml_content_matches_inline_for_all_templates(self):
|
|
"""All 16 inline templates must match their YAML siblings exactly.
|
|
|
|
v0.39.0 Part E ships both sources for back-compat; drift between
|
|
them is a contributor footgun. This guards against silent edits
|
|
of one source without the other.
|
|
"""
|
|
from soup_cli.config.schema import TEMPLATES
|
|
from soup_cli.templates import load_template
|
|
for name in TEMPLATES:
|
|
assert load_template(name) == TEMPLATES[name], (
|
|
f"YAML / inline drift for template {name!r}"
|
|
)
|
|
|
|
|
|
class TestSecurityFallbacks:
|
|
def test_oversized_file_falls_back_to_inline(self, tmp_path, monkeypatch):
|
|
"""v0.39.0 — _MAX_TEMPLATE_BYTES guard."""
|
|
import soup_cli.templates as tpl_mod
|
|
|
|
fake_dir = tmp_path / "templates"
|
|
fake_dir.mkdir()
|
|
(fake_dir / "manifest.json").write_text(
|
|
json.dumps({"templates": {"chat": "chat.yaml"}, "version": 1})
|
|
)
|
|
# Write a 300 KB file (> 256 KB cap)
|
|
(fake_dir / "chat.yaml").write_text("x" * (300 * 1024))
|
|
|
|
monkeypatch.setattr(tpl_mod, "_templates_dir", lambda: fake_dir)
|
|
# Should fall back to inline TEMPLATES["chat"], not return the giant blob.
|
|
body = tpl_mod.load_template("chat")
|
|
from soup_cli.config.schema import TEMPLATES
|
|
assert body == TEMPLATES["chat"]
|
|
assert len(body) < 256 * 1024
|
|
|
|
def test_crafted_manifest_outside_dir_falls_back(self, tmp_path, monkeypatch):
|
|
"""A manifest entry with a relative path that escapes must be ignored."""
|
|
import soup_cli.templates as tpl_mod
|
|
|
|
fake_dir = tmp_path / "templates"
|
|
fake_dir.mkdir()
|
|
# Create a file OUTSIDE the templates dir
|
|
(tmp_path / "secret.yaml").write_text("LEAKED CONTENT")
|
|
# Crafted manifest pointing to it via filename traversal-like component
|
|
# _validate_name will reject any name with `..`/`/`/`\` so this also
|
|
# exercises the manifest-tampering rejection chain.
|
|
(fake_dir / "manifest.json").write_text(
|
|
json.dumps({"templates": {"chat": "../secret.yaml"}, "version": 1})
|
|
)
|
|
|
|
monkeypatch.setattr(tpl_mod, "_templates_dir", lambda: fake_dir)
|
|
body = tpl_mod.load_template("chat")
|
|
from soup_cli.config.schema import TEMPLATES
|
|
# Must NOT contain the leaked content; falls back to inline.
|
|
assert "LEAKED CONTENT" not in (body or "")
|
|
assert body == TEMPLATES["chat"]
|
|
|
|
|
|
class TestInitUsesRegistry:
|
|
def test_init_command_lists_template_options(self):
|
|
from typer.testing import CliRunner
|
|
|
|
from soup_cli.cli import app
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, ["init", "--help"])
|
|
# init --help should still succeed after the migration
|
|
assert result.exit_code == 0, (result.output, repr(result.exception))
|