Skip to content

Commit c167ead

Browse files
fralapoclaude
andcommitted
test(review): Phase 0 safety net for round-2/3 fixes
Host-testable guards before the perf/refactor phases touch behavior: - test_download.py: _reject_rebound_internal (SSRF DNS-rebinding) — literal internal/public IPs, all-internal vs mixed resolution, no-host, swallowed resolution failure - test_zernio_schema.py: ZernioConfigRequest bounds (api_key/timezone length, accounts count/platform/id) + PublishRequest.scheduled_for max_length - test_status_logs_slice.py: GET /api/status caps the log tail at 500 lines Host 437 passed/3 skipped. Note: batch-orphan-cleanup and reframe-save-500 paths need async subprocess/queue mocking — left as a known test gap (logic manually verified, exercised indirectly by integration). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01VDkEfcWP5suCdutKJKnvCM
1 parent fefb292 commit c167ead

3 files changed

Lines changed: 170 additions & 0 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
"""GET /api/status caps the returned log tail.
2+
3+
The per-job log buffer is bounded at the producer (job_worker.MAX_LOG_LINES),
4+
but the status response slices to the last 500 lines so a long job never ships
5+
a multi-thousand-line payload on every 2s poll. Driven with TestClient WITHOUT
6+
the lifespan (same pattern as test_job_controls).
7+
"""
8+
from fastapi.testclient import TestClient
9+
10+
from clippyme.api import app as app_module
11+
12+
JOB_ID = "22222222-2222-4222-8222-222222222222"
13+
ORIGIN = {"Origin": "http://localhost:5175"}
14+
15+
16+
def _client():
17+
return TestClient(app_module.app, headers=ORIGIN)
18+
19+
20+
def test_status_logs_sliced_to_last_500():
21+
app_module.jobs.pop(JOB_ID, None)
22+
try:
23+
app_module.jobs[JOB_ID] = {
24+
"status": "processing",
25+
"logs": [f"line {i}" for i in range(1500)],
26+
"result": None,
27+
}
28+
r = _client().get(f"/api/status/{JOB_ID}")
29+
assert r.status_code == 200
30+
logs = r.json().get("logs", [])
31+
assert len(logs) == 500
32+
assert logs[-1] == "line 1499"
33+
assert logs[0] == "line 1000"
34+
finally:
35+
app_module.jobs.pop(JOB_ID, None)
36+
37+
38+
def test_status_short_logs_unchanged():
39+
app_module.jobs.pop(JOB_ID, None)
40+
try:
41+
app_module.jobs[JOB_ID] = {
42+
"status": "processing",
43+
"logs": ["a", "b", "c"],
44+
"result": None,
45+
}
46+
r = _client().get(f"/api/status/{JOB_ID}")
47+
assert r.status_code == 200
48+
assert r.json().get("logs") == ["a", "b", "c"]
49+
finally:
50+
app_module.jobs.pop(JOB_ID, None)

tests/api/test_zernio_schema.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
"""Bounds tests for ZernioConfigRequest + PublishRequest.scheduled_for.
2+
3+
The Zernio config endpoint and publish schedule accept user-supplied strings
4+
that land in data/config.json and the scheduler. These pin the size/shape
5+
guards: unbounded api_key, oversized/odd accounts maps, and a giant
6+
scheduled_for string are all rejected at the Pydantic boundary; normal
7+
payloads pass.
8+
"""
9+
import pytest
10+
from pydantic import ValidationError
11+
12+
from clippyme.api.schemas import PublishRequest, ZernioConfigRequest
13+
14+
15+
def test_zernio_accepts_normal_config():
16+
req = ZernioConfigRequest(
17+
api_key="sk_live_abc123",
18+
accounts={"tiktok": "acc1", "youtube": "acc2", "instagram": None},
19+
timezone="Europe/Rome",
20+
)
21+
assert req.accounts["tiktok"] == "acc1"
22+
23+
24+
def test_zernio_accepts_empty():
25+
req = ZernioConfigRequest()
26+
assert req.api_key is None
27+
28+
29+
def test_zernio_rejects_oversized_api_key():
30+
with pytest.raises(ValidationError):
31+
ZernioConfigRequest(api_key="x" * 513)
32+
33+
34+
def test_zernio_rejects_oversized_timezone():
35+
with pytest.raises(ValidationError):
36+
ZernioConfigRequest(timezone="z" * 65)
37+
38+
39+
def test_zernio_rejects_too_many_accounts():
40+
with pytest.raises(ValidationError):
41+
ZernioConfigRequest(accounts={f"k{i}": "v" for i in range(17)})
42+
43+
44+
def test_zernio_rejects_unknown_platform():
45+
with pytest.raises(ValidationError):
46+
ZernioConfigRequest(accounts={"myspace": "a"})
47+
48+
49+
def test_zernio_rejects_oversized_account_id():
50+
with pytest.raises(ValidationError):
51+
ZernioConfigRequest(accounts={"tiktok": "a" * 257})
52+
53+
54+
def test_zernio_rejects_nonstring_account_id():
55+
with pytest.raises(ValidationError):
56+
ZernioConfigRequest(accounts={"tiktok": {"nested": 1}})
57+
58+
59+
def test_publish_accepts_normal_scheduled_for():
60+
req = PublishRequest(
61+
platforms=[{"platform": "tiktok", "accountId": "a"}],
62+
scheduled_for="2026-04-08T12:00:00",
63+
)
64+
assert req.scheduled_for.startswith("2026")
65+
66+
67+
def test_publish_rejects_oversized_scheduled_for():
68+
with pytest.raises(ValidationError):
69+
PublishRequest(
70+
platforms=[{"platform": "tiktok", "accountId": "a"}],
71+
scheduled_for="9" * 65,
72+
)

tests/pipeline/test_download.py

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,57 @@
11
"""Tests for clippyme.pipeline.download helpers (host-runnable; no network)."""
22
import os
33

4+
import pytest
5+
46
from clippyme.pipeline import download as dl
57

68

9+
def _fake_getaddrinfo(*ips):
10+
"""Build a getaddrinfo stub returning the given IP strings."""
11+
def _stub(host, port, *a, **k):
12+
return [(None, None, None, "", (ip, 0)) for ip in ips]
13+
return _stub
14+
15+
16+
def test_reject_rebound_literal_internal_ip_raises():
17+
with pytest.raises(ValueError):
18+
dl._reject_rebound_internal("http://127.0.0.1/video")
19+
20+
21+
def test_reject_rebound_literal_public_ip_passes():
22+
# 8.8.8.8 is public — must not raise.
23+
dl._reject_rebound_internal("http://8.8.8.8/video")
24+
25+
26+
def test_reject_rebound_all_internal_resolution_raises(monkeypatch):
27+
monkeypatch.setattr(dl.socket, "getaddrinfo", _fake_getaddrinfo("192.168.1.10", "127.0.0.1"))
28+
with pytest.raises(ValueError):
29+
dl._reject_rebound_internal("http://rebind.evil.test/x")
30+
31+
32+
def test_reject_rebound_public_resolution_passes(monkeypatch):
33+
monkeypatch.setattr(dl.socket, "getaddrinfo", _fake_getaddrinfo("93.184.216.34"))
34+
dl._reject_rebound_internal("http://example.com/x") # no raise
35+
36+
37+
def test_reject_rebound_mixed_public_and_internal_passes(monkeypatch):
38+
# Not ALL internal → allowed (only blocks when every address is internal).
39+
monkeypatch.setattr(dl.socket, "getaddrinfo", _fake_getaddrinfo("93.184.216.34", "10.0.0.1"))
40+
dl._reject_rebound_internal("http://example.com/x") # no raise
41+
42+
43+
def test_reject_rebound_no_host_returns_none():
44+
assert dl._reject_rebound_internal("not a url") is None
45+
46+
47+
def test_reject_rebound_resolution_failure_is_swallowed(monkeypatch):
48+
def _boom(*a, **k):
49+
raise OSError("dns down")
50+
monkeypatch.setattr(dl.socket, "getaddrinfo", _boom)
51+
# Resolution hiccup must not block a legit download — yt-dlp handles it.
52+
assert dl._reject_rebound_internal("http://example.com/x") is None
53+
54+
755
def test_sanitize_filename_strips_invalid_chars():
856
assert dl.sanitize_filename('a<b>c:d"e/f\\g|h?i*j') == "abcdefghij"
957

0 commit comments

Comments
 (0)