DevPlace CI / test (push) Failing after 44m45s
Continuous Delivery could never be turned off once a crop was planted. Add a one-shot per-plot "stop after harvest" flag (POST /game/plot/stop, Devii tool game_plot_stop, game_plots.stop_after_harvest), cleared by the next plant, autoreplant claim or Refactor. Autoreplant now also respects crop locks through the shared _crop_lock predicate. Plan and measurements in happyplanting.md. Also includes the pending community sync, node network and related work in the tree; the full suite passed (4999) with all of it. Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
227 lines
7.7 KiB
Python
227 lines
7.7 KiB
Python
# retoor <retoor@molodetz.nl>
|
|
|
|
import pytest
|
|
|
|
from devplacepy.services.skills import limits
|
|
from devplacepy.services.skills.fetcher import (
|
|
SkillFetchError,
|
|
classify_candidate,
|
|
decode_text,
|
|
directory_url,
|
|
extract_candidates,
|
|
normalize_input_url,
|
|
origin_of,
|
|
)
|
|
from devplacepy.services.skills.parser import SkillFormatError
|
|
|
|
|
|
def test_normalize_adds_scheme_and_strips_fragment():
|
|
assert normalize_input_url("example.org/skill#frag") == "https://example.org/skill"
|
|
assert normalize_input_url(" http://example.org/a/SKILL.md ") == "http://example.org/a/SKILL.md"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"raw",
|
|
["", " ", "ftp://example.org/x", "https://user:pw@example.org/x", "x" * (limits.URL_MAX_CHARS + 1)],
|
|
)
|
|
def test_normalize_rejects_bad_urls(raw):
|
|
with pytest.raises(SkillFetchError):
|
|
normalize_input_url(raw)
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"url,expected",
|
|
[
|
|
("https://e.org/a/SKILL.md", "https://e.org/a/"),
|
|
("https://e.org/a/index.html", "https://e.org/a/"),
|
|
("https://e.org/a", "https://e.org/a/"),
|
|
("https://e.org/a/", "https://e.org/a/"),
|
|
("https://e.org", "https://e.org/"),
|
|
],
|
|
)
|
|
def test_directory_url(url, expected):
|
|
assert directory_url(url) == expected
|
|
|
|
|
|
def test_origin_uses_default_ports():
|
|
assert origin_of("https://Example.org/x") == ("https", "example.org", 443)
|
|
assert origin_of("http://example.org:8080/x") == ("http", "example.org", 8080)
|
|
|
|
|
|
def test_candidates_come_from_links_and_code_spans_once():
|
|
body = (
|
|
"See [the script](scripts/run.py) and `references/guide.md`.\n"
|
|
"Again [dup](scripts/run.py#anchor) and [other](docs/readme.md) and `not a path`."
|
|
)
|
|
assert extract_candidates(body) == ["scripts/run.py", "references/guide.md"]
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
"candidate,accepted",
|
|
[
|
|
("scripts/run.py", True),
|
|
("./references/guide.md", True),
|
|
("assets/templates/SPEC.md", True),
|
|
("assets/img/hero.png", False),
|
|
("../scripts/run.py", False),
|
|
("scripts/../../etc/passwd", False),
|
|
("scripts/%2e%2e/x.py", False),
|
|
("scripts/a.py?x=1", False),
|
|
("/scripts/run.py", False),
|
|
("https://evil.org/scripts/run.py", False),
|
|
("scripts\\run.py", False),
|
|
("other/run.py", False),
|
|
("scripts", False),
|
|
("scripts/sp ace.py", False),
|
|
],
|
|
)
|
|
def test_classify_candidate_only_accepts_safe_relative_text_paths(candidate, accepted):
|
|
path, reason = classify_candidate(candidate)
|
|
assert (path is not None) is accepted
|
|
assert (reason == "") is accepted
|
|
|
|
|
|
def test_overlong_path_is_rejected():
|
|
path, reason = classify_candidate("scripts/" + "a" * limits.BUNDLED_PATH_MAX_CHARS + ".py")
|
|
assert path is None and reason
|
|
|
|
|
|
def test_decode_text():
|
|
assert decode_text("héllo".encode("utf-8")) == "héllo"
|
|
with pytest.raises(SkillFormatError):
|
|
decode_text(b"a\x00b")
|
|
with pytest.raises(SkillFormatError):
|
|
decode_text(b"\xff\xfe\xfa")
|
|
|
|
|
|
import ipaddress
|
|
import socket
|
|
import threading
|
|
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
|
|
from devplacepy.net_guard import is_blocked_address
|
|
from devplacepy.services.skills.fetcher import fetch_skill
|
|
from tests.conftest import run_async
|
|
|
|
SKILL_TEXT = (
|
|
"---\nname: wire-skill\ndescription: Fetched over a real socket.\n---\n"
|
|
"# Wire skill\n\nRead [the guide](references/guide.md) and `references/missing.md`.\n"
|
|
)
|
|
|
|
|
|
class Handler(BaseHTTPRequestHandler):
|
|
routes: dict = {}
|
|
|
|
def do_GET(self):
|
|
entry = self.routes.get(self.path.split("?", 1)[0])
|
|
if entry is None:
|
|
self.send_response(404)
|
|
self.end_headers()
|
|
return
|
|
status, headers, body = entry
|
|
self.send_response(status)
|
|
for name, value in headers.items():
|
|
self.send_header(name, value)
|
|
self.send_header("Content-Length", str(len(body)))
|
|
self.end_headers()
|
|
self.wfile.write(body)
|
|
|
|
def log_message(self, *args):
|
|
return None
|
|
|
|
|
|
def global_address() -> str | None:
|
|
probe = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
|
try:
|
|
probe.connect(("8.8.8.8", 80))
|
|
address = probe.getsockname()[0]
|
|
except OSError:
|
|
return None
|
|
finally:
|
|
probe.close()
|
|
return None if is_blocked_address(ipaddress.ip_address(address)) else address
|
|
|
|
|
|
needs_global_address = pytest.mark.skipif(
|
|
global_address() is None,
|
|
reason="this host has no globally routable IPv4 address, so a local server cannot pass the SSRF guard without patching it",
|
|
)
|
|
|
|
|
|
@pytest.fixture
|
|
def origin():
|
|
port_holder = {}
|
|
|
|
def text(body, kind="text/markdown"):
|
|
return 200, {"Content-Type": kind}, body.encode()
|
|
|
|
Handler.routes = {
|
|
"/ok/": text("<html><body>skill directory</body></html>", "text/html"),
|
|
"/ok/SKILL.md": text(SKILL_TEXT),
|
|
"/ok/references/guide.md": text("# Guide\n\nStep one.\n"),
|
|
"/big/SKILL.md": text("---\nname: big\ndescription: too big\n---\n" + "x" * (limits.SKILL_MD_MAX_BYTES + 10)),
|
|
"/html/SKILL.md": text("<html><body>not a skill</body></html>", "text/html"),
|
|
"/bounce/SKILL.md": (302, {"Location": "PLACEHOLDER"}, b""),
|
|
"/loop/SKILL.md": (302, {"Location": "/loop/SKILL.md"}, b""),
|
|
}
|
|
server = ThreadingHTTPServer(("0.0.0.0", 0), Handler)
|
|
port_holder["port"] = server.server_address[1]
|
|
thread = threading.Thread(target=server.serve_forever, daemon=True)
|
|
thread.start()
|
|
yield port_holder["port"]
|
|
server.shutdown()
|
|
server.server_close()
|
|
|
|
|
|
@needs_global_address
|
|
def test_a_skill_is_fetched_over_a_real_socket_with_its_bundled_file(origin):
|
|
address = global_address()
|
|
fetched = run_async(fetch_skill(f"http://{address}:{origin}/ok/SKILL.md"))
|
|
assert fetched.parsed.name == "wire-skill" and fetched.source_url.endswith("/ok/SKILL.md")
|
|
assert fetched.files == {"references/guide.md": "# Guide\n\nStep one.\n"}
|
|
assert [(item.path, item.reason) for item in fetched.skipped] == [("references/missing.md", "the server returned 404")]
|
|
|
|
|
|
@needs_global_address
|
|
def test_the_directory_address_resolves_to_the_skill_file(origin):
|
|
address = global_address()
|
|
fetched = run_async(fetch_skill(f"http://{address}:{origin}/ok/"))
|
|
assert fetched.parsed.name == "wire-skill"
|
|
|
|
|
|
@needs_global_address
|
|
def test_loopback_is_refused_even_though_the_server_is_right_there(origin):
|
|
with pytest.raises(SkillFetchError, match="private or local"):
|
|
run_async(fetch_skill(f"http://127.0.0.1:{origin}/ok/SKILL.md"))
|
|
|
|
|
|
@needs_global_address
|
|
def test_a_redirect_to_loopback_is_refused_on_the_second_hop(origin):
|
|
address = global_address()
|
|
Handler.routes["/bounce/SKILL.md"] = (302, {"Location": f"http://127.0.0.1:{origin}/ok/SKILL.md"}, b"")
|
|
with pytest.raises(SkillFetchError, match="private or local"):
|
|
run_async(fetch_skill(f"http://{address}:{origin}/bounce/SKILL.md"))
|
|
|
|
|
|
@needs_global_address
|
|
def test_a_redirect_loop_ends_with_a_clean_error(origin):
|
|
address = global_address()
|
|
with pytest.raises(SkillFetchError):
|
|
run_async(fetch_skill(f"http://{address}:{origin}/loop/SKILL.md"))
|
|
|
|
|
|
@needs_global_address
|
|
def test_an_oversize_skill_is_cut_at_the_limit_and_html_is_rejected(origin):
|
|
address = global_address()
|
|
with pytest.raises(SkillFetchError, match="byte limit"):
|
|
run_async(fetch_skill(f"http://{address}:{origin}/big/SKILL.md"))
|
|
with pytest.raises(SkillFetchError, match="HTML page"):
|
|
run_async(fetch_skill(f"http://{address}:{origin}/html/SKILL.md"))
|
|
|
|
|
|
@needs_global_address
|
|
def test_a_missing_skill_reports_the_status(origin):
|
|
address = global_address()
|
|
with pytest.raises(SkillFetchError, match="404"):
|
|
run_async(fetch_skill(f"http://{address}:{origin}/nothing/SKILL.md"))
|