mirror of https://github.com/razor-ai/soup.git
376 lines
14 KiB
Python
376 lines
14 KiB
Python
"""Tests for v0.60.0 Part E — License-conflict matrix at adapter merge.
|
|
|
|
Coverage:
|
|
- ``LicenseCompatibilityMatrix`` closed allowlist + immutability
|
|
- ``check_license_compat`` happy + conflict matrix
|
|
- ``LicenseConflictReport`` frozen + reason explainability
|
|
- ``--license-override <reason>`` opt-in trail
|
|
- Integration: ``adapter_merge`` refuses on conflict (helper used; CLI test
|
|
smokes the flag wiring)
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import dataclasses
|
|
import types
|
|
|
|
import pytest
|
|
|
|
|
|
class TestLicenseMatrix:
|
|
def test_imports(self):
|
|
from soup_cli.utils.license_matrix import (
|
|
KNOWN_LICENSES,
|
|
LICENSE_MATRIX,
|
|
LicenseConflictReport,
|
|
check_license_compat,
|
|
normalise_license_id,
|
|
)
|
|
assert callable(check_license_compat)
|
|
assert callable(normalise_license_id)
|
|
assert isinstance(KNOWN_LICENSES, frozenset)
|
|
assert isinstance(LICENSE_MATRIX, types.MappingProxyType)
|
|
assert dataclasses.is_dataclass(LicenseConflictReport)
|
|
|
|
def test_known_licenses_includes_common(self):
|
|
from soup_cli.utils.license_matrix import KNOWN_LICENSES
|
|
|
|
for lic in (
|
|
"apache-2.0", "mit", "bsd-3-clause",
|
|
"llama-3", "llama-3.1", "llama-community",
|
|
"gemma", "qwen-research",
|
|
"gpl-3.0", "agpl-3.0",
|
|
"cc-by-4.0", "cc-by-nc-4.0",
|
|
"openrail", "creativeml-openrail-m",
|
|
"openai-tos",
|
|
):
|
|
assert lic in KNOWN_LICENSES, f"missing license: {lic}"
|
|
|
|
def test_matrix_immutable(self):
|
|
from soup_cli.utils.license_matrix import LICENSE_MATRIX
|
|
|
|
with pytest.raises(TypeError):
|
|
LICENSE_MATRIX["evil"] = ("anything",) # type: ignore[index]
|
|
|
|
def test_normalise_license_id(self):
|
|
from soup_cli.utils.license_matrix import normalise_license_id
|
|
|
|
assert normalise_license_id("Apache-2.0") == "apache-2.0"
|
|
assert normalise_license_id("MIT") == "mit"
|
|
assert normalise_license_id(" llama-3 ") == "llama-3"
|
|
|
|
def test_normalise_rejects_null_byte(self):
|
|
from soup_cli.utils.license_matrix import normalise_license_id
|
|
|
|
with pytest.raises(ValueError):
|
|
normalise_license_id("apache\x00")
|
|
|
|
def test_normalise_rejects_bool(self):
|
|
from soup_cli.utils.license_matrix import normalise_license_id
|
|
|
|
with pytest.raises(TypeError):
|
|
normalise_license_id(True)
|
|
|
|
|
|
class TestCheckCompat:
|
|
def test_clean_apache_only_passes(self):
|
|
from soup_cli.utils.license_matrix import check_license_compat
|
|
|
|
report = check_license_compat(["apache-2.0", "apache-2.0"])
|
|
assert report.ok is True
|
|
assert report.conflict_pair is None
|
|
|
|
def test_apache_with_mit_passes(self):
|
|
from soup_cli.utils.license_matrix import check_license_compat
|
|
|
|
report = check_license_compat(["apache-2.0", "mit"])
|
|
assert report.ok is True
|
|
|
|
def test_apache_with_gpl_conflict(self):
|
|
from soup_cli.utils.license_matrix import check_license_compat
|
|
|
|
# Locked verdict: GPL-3.0 (strong copyleft) alongside Apache-2.0
|
|
# (permissive) is a refused conflict in the v0.60.0 matrix. A
|
|
# future relax requires both a matrix change and this test
|
|
# change in the same PR — drift guard.
|
|
report = check_license_compat(["apache-2.0", "gpl-3.0"])
|
|
assert report.ok is False
|
|
assert report.conflict_pair is not None
|
|
assert "copyleft" in report.reason.lower()
|
|
|
|
def test_nc_license_with_apache_conflict(self):
|
|
from soup_cli.utils.license_matrix import check_license_compat
|
|
|
|
# NC (non-commercial) cannot be combined with any permissive license
|
|
# that doesn't carry the same restriction.
|
|
report = check_license_compat(["apache-2.0", "cc-by-nc-4.0"])
|
|
assert report.ok is False
|
|
assert "nc" in report.reason.lower() or "non-commercial" in report.reason.lower()
|
|
|
|
def test_unknown_license_treated_as_conflict_or_warn(self):
|
|
from soup_cli.utils.license_matrix import check_license_compat
|
|
|
|
report = check_license_compat(["apache-2.0", "weird-unknown-license-xyz"])
|
|
assert report.ok is False
|
|
assert "unknown" in report.reason.lower()
|
|
|
|
def test_empty_list_rejected(self):
|
|
from soup_cli.utils.license_matrix import check_license_compat
|
|
|
|
with pytest.raises(ValueError):
|
|
check_license_compat([])
|
|
|
|
def test_single_license_passes(self):
|
|
from soup_cli.utils.license_matrix import check_license_compat
|
|
|
|
report = check_license_compat(["apache-2.0"])
|
|
assert report.ok is True
|
|
|
|
def test_rejects_non_list(self):
|
|
from soup_cli.utils.license_matrix import check_license_compat
|
|
|
|
with pytest.raises(TypeError):
|
|
check_license_compat("apache-2.0") # type: ignore[arg-type]
|
|
|
|
def test_llama_with_apache_conflict(self):
|
|
from soup_cli.utils.license_matrix import check_license_compat
|
|
|
|
# Locked verdict: Llama community license adds acceptable-use
|
|
# restrictions that Apache does not satisfy. v0.60.0 design is
|
|
# strict — refuse and force operator override.
|
|
report = check_license_compat(["llama-3", "apache-2.0"])
|
|
assert report.ok is False
|
|
assert report.conflict_pair is not None
|
|
assert "restricted" in report.reason.lower() or "acceptable-use" in report.reason.lower()
|
|
|
|
def test_case_insensitive(self):
|
|
from soup_cli.utils.license_matrix import check_license_compat
|
|
|
|
report = check_license_compat(["Apache-2.0", "MIT"])
|
|
assert report.ok is True
|
|
|
|
def test_report_frozen(self):
|
|
from soup_cli.utils.license_matrix import check_license_compat
|
|
|
|
report = check_license_compat(["apache-2.0"])
|
|
with pytest.raises(dataclasses.FrozenInstanceError):
|
|
report.ok = False # type: ignore[misc]
|
|
|
|
|
|
class TestOverride:
|
|
def test_override_reason_required(self):
|
|
from soup_cli.utils.license_matrix import (
|
|
validate_license_override_reason,
|
|
)
|
|
# Empty reason rejected.
|
|
with pytest.raises(ValueError):
|
|
validate_license_override_reason("")
|
|
with pytest.raises(TypeError):
|
|
validate_license_override_reason(None) # type: ignore[arg-type]
|
|
|
|
def test_override_reason_min_length(self):
|
|
from soup_cli.utils.license_matrix import (
|
|
validate_license_override_reason,
|
|
)
|
|
# A reason of 1-2 chars is too short — defends against `--license-override y`.
|
|
with pytest.raises(ValueError):
|
|
validate_license_override_reason("ok")
|
|
|
|
def test_override_reason_oversize_rejected(self):
|
|
from soup_cli.utils.license_matrix import (
|
|
validate_license_override_reason,
|
|
)
|
|
# 4kb cap to prevent log bloat.
|
|
with pytest.raises(ValueError):
|
|
validate_license_override_reason("x" * 5000)
|
|
|
|
def test_override_reason_exact_boundary(self):
|
|
"""Exact-boundary test at 4096 / 4097 chars."""
|
|
from soup_cli.utils.license_matrix import (
|
|
validate_license_override_reason,
|
|
)
|
|
# Accepted: exactly 4096 chars.
|
|
result = validate_license_override_reason("x" * 4096)
|
|
assert len(result) == 4096
|
|
# Rejected: 4097.
|
|
with pytest.raises(ValueError):
|
|
validate_license_override_reason("x" * 4097)
|
|
|
|
def test_override_reason_min_boundary(self):
|
|
"""Exact-boundary test at 7 / 8 chars (min cap)."""
|
|
from soup_cli.utils.license_matrix import (
|
|
validate_license_override_reason,
|
|
)
|
|
# Rejected: 7 chars.
|
|
with pytest.raises(ValueError):
|
|
validate_license_override_reason("x" * 7)
|
|
# Accepted: 8 chars.
|
|
result = validate_license_override_reason("x" * 8)
|
|
assert result == "x" * 8
|
|
|
|
def test_override_reason_null_byte(self):
|
|
from soup_cli.utils.license_matrix import (
|
|
validate_license_override_reason,
|
|
)
|
|
with pytest.raises(ValueError):
|
|
validate_license_override_reason("legal cleared\x00")
|
|
|
|
def test_override_reason_happy(self):
|
|
from soup_cli.utils.license_matrix import (
|
|
validate_license_override_reason,
|
|
)
|
|
result = validate_license_override_reason(
|
|
"legal-cleared by alice@example.com on 2026-05-19"
|
|
)
|
|
assert result.startswith("legal-cleared")
|
|
|
|
|
|
class TestMergeIntegration:
|
|
"""Regression: license gate is wired into `soup adapters merge`."""
|
|
|
|
def test_merge_help_lists_license_flags(self):
|
|
"""Both --license and --license-override must surface in the help text.
|
|
|
|
Rich's table renderer can split option names across ANSI styling
|
|
escape sequences, so a naive ``"--license" in result.output`` may
|
|
miss the bare option. We strip ANSI codes first.
|
|
"""
|
|
import re
|
|
|
|
from typer.testing import CliRunner
|
|
|
|
from soup_cli.cli import app
|
|
runner = CliRunner()
|
|
result = runner.invoke(app, ["adapters", "merge", "--help"])
|
|
assert result.exit_code == 0, (result.output, repr(result.exception))
|
|
# Strip ANSI escape sequences; merge whitespace.
|
|
clean = re.sub(r"\x1b\[[0-9;]*[mGKHF]", "", result.output)
|
|
clean = re.sub(r"\s+", " ", clean)
|
|
assert "--license-override" in clean
|
|
# Bare --license option (Typer may render with TEXT type hint
|
|
# immediately after); accept either form.
|
|
assert "--license " in clean or "--license\n" in clean or "--license " in result.output
|
|
|
|
def test_merge_refuses_license_conflict_without_override(self, tmp_path, monkeypatch):
|
|
"""Two adapters with incompatible licenses + no override → exit 3."""
|
|
from typer.testing import CliRunner
|
|
|
|
from soup_cli.cli import app
|
|
monkeypatch.chdir(tmp_path)
|
|
# Create two minimal adapter dirs — the license gate fires before
|
|
# the actual merge math, so we don't need real safetensors.
|
|
for name in ("a", "b"):
|
|
d = tmp_path / name
|
|
d.mkdir()
|
|
(d / "adapter_config.json").write_text("{}", encoding="utf-8")
|
|
(d / "adapter_model.safetensors").write_bytes(b"x")
|
|
runner = CliRunner()
|
|
result = runner.invoke(
|
|
app, [
|
|
"adapters", "merge", "a", "b",
|
|
"-o", "out", "--allow-unscanned",
|
|
"--license", "apache-2.0",
|
|
"--license", "cc-by-nc-4.0",
|
|
]
|
|
)
|
|
assert result.exit_code == 3, (result.output, repr(result.exception))
|
|
assert "license conflict" in result.output.lower()
|
|
|
|
def test_merge_override_accepted_with_reason(self, tmp_path, monkeypatch):
|
|
"""Conflict + valid --license-override reason → merge proceeds.
|
|
|
|
The merge math will fail downstream because the fixture adapters
|
|
are not real safetensors; we assert the license gate let us
|
|
through (exit != 3 — gate-pass, even if merge math fails later).
|
|
"""
|
|
from typer.testing import CliRunner
|
|
|
|
from soup_cli.cli import app
|
|
monkeypatch.chdir(tmp_path)
|
|
# v0.71.2 #190 — the override path now writes an audit record; keep it
|
|
# out of the real ~/.soup during tests.
|
|
monkeypatch.setenv("SOUP_AUDIT_LOG_PATH", str(tmp_path / "audit.jsonl"))
|
|
for name in ("a", "b"):
|
|
d = tmp_path / name
|
|
d.mkdir()
|
|
(d / "adapter_config.json").write_text("{}", encoding="utf-8")
|
|
(d / "adapter_model.safetensors").write_bytes(b"x")
|
|
runner = CliRunner()
|
|
result = runner.invoke(
|
|
app, [
|
|
"adapters", "merge", "a", "b",
|
|
"-o", "out", "--allow-unscanned",
|
|
"--license", "apache-2.0",
|
|
"--license", "cc-by-nc-4.0",
|
|
"--license-override", "legal-cleared 2026-05-19 by alice",
|
|
]
|
|
)
|
|
# Gate passed (no exit 3); downstream merge math likely fails on
|
|
# the synthetic safetensors fixture.
|
|
assert result.exit_code != 3, (result.output, repr(result.exception))
|
|
# Override message surfaced (either the explicit "overridden"
|
|
# banner, or absence of the unmitigated "License conflict refused"
|
|
# banner shows the gate did pass).
|
|
out_lower = result.output.lower()
|
|
assert ("overridden" in out_lower) or ("license conflict refused" not in out_lower)
|
|
|
|
def test_merge_override_reason_too_short_rejected(self, tmp_path, monkeypatch):
|
|
from typer.testing import CliRunner
|
|
|
|
from soup_cli.cli import app
|
|
monkeypatch.chdir(tmp_path)
|
|
for name in ("a", "b"):
|
|
d = tmp_path / name
|
|
d.mkdir()
|
|
(d / "adapter_config.json").write_text("{}", encoding="utf-8")
|
|
(d / "adapter_model.safetensors").write_bytes(b"x")
|
|
runner = CliRunner()
|
|
result = runner.invoke(
|
|
app, [
|
|
"adapters", "merge", "a", "b",
|
|
"-o", "out", "--allow-unscanned",
|
|
"--license", "apache-2.0",
|
|
"--license", "cc-by-nc-4.0",
|
|
"--license-override", "ok", # < 8 chars
|
|
]
|
|
)
|
|
assert result.exit_code == 2
|
|
assert "short" in result.output.lower() or "8" in result.output
|
|
|
|
def test_merge_license_count_mismatch_rejected(self, tmp_path, monkeypatch):
|
|
from typer.testing import CliRunner
|
|
|
|
from soup_cli.cli import app
|
|
monkeypatch.chdir(tmp_path)
|
|
for name in ("a", "b"):
|
|
d = tmp_path / name
|
|
d.mkdir()
|
|
(d / "adapter_config.json").write_text("{}", encoding="utf-8")
|
|
(d / "adapter_model.safetensors").write_bytes(b"x")
|
|
runner = CliRunner()
|
|
# Only one license for two adapters — mismatch.
|
|
result = runner.invoke(
|
|
app, [
|
|
"adapters", "merge", "a", "b",
|
|
"-o", "out", "--allow-unscanned",
|
|
"--license", "apache-2.0",
|
|
]
|
|
)
|
|
assert result.exit_code == 2
|
|
assert "must match" in result.output.lower() or "count" in result.output.lower()
|
|
|
|
|
|
class TestSourceWiring:
|
|
def test_module_imports(self):
|
|
from soup_cli.utils import license_matrix as m
|
|
|
|
assert hasattr(m, "check_license_compat")
|
|
assert hasattr(m, "KNOWN_LICENSES")
|
|
assert hasattr(m, "LICENSE_MATRIX")
|
|
|
|
def test_matrix_has_at_least_20_entries(self):
|
|
from soup_cli.utils.license_matrix import KNOWN_LICENSES
|
|
|
|
# Plan says top-30 licenses — at least 20 must be cataloged.
|
|
assert len(KNOWN_LICENSES) >= 20
|