2026-11-06 08:28:04 +00:00
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
import os
|
|
|
|
|
import threading
|
|
|
|
|
import time
|
|
|
|
|
import unittest
|
|
|
|
|
from pathlib import Path
|
|
|
|
|
from unittest import mock
|
|
|
|
|
|
|
|
|
|
from hub_case import HubCase
|
|
|
|
|
from hub_support import PAGE, TREE, key
|
2026-11-06 22:20:06 +00:00
|
|
|
from kvb_orchestrator import ledger, review_page, reviewing
|
2026-11-06 08:28:04 +00:00
|
|
|
|
|
|
|
|
REVIEW_PAGE = """<!DOCTYPE html><html><head><script>const x = "<details class='file'>";</script></head><body>
|
|
|
|
|
<section class="group" id="staged" data-files="2">
|
|
|
|
|
<h2>Staged <span class="muted">2 files</span><span class="stat"><span class="add">+2</span><span class="del">−1</span></span></h2>
|
|
|
|
|
<details class="file" id="f1" data-path="hub/server.py" open>
|
|
|
|
|
<summary>
|
|
|
|
|
<span class="badge modified" title="modified">M</span>
|
|
|
|
|
<span class="path">hub/server.py</span>
|
|
|
|
|
<span class="stat"><span class="add">+2</span><span class="del">−1</span></span>
|
|
|
|
|
<a class="open" href="vscode://file/x" title="Open in VS Code">Open ↗</a>
|
|
|
|
|
</summary>
|
|
|
|
|
<table class="diff">
|
|
|
|
|
<tbody>
|
|
|
|
|
<tr class="hunk"><td class="num" colspan="2"></td><td class="code" colspan="2">@@ -1,2 +1,3 @@</td></tr>
|
|
|
|
|
<tr class="ctx"><td class="num">1</td><td class="num"><a href="vscode://file/x:1">1</a></td><td class="sign"></td><td class="code">import os</td></tr>
|
|
|
|
|
<tr class="del"><td class="num">2</td><td class="num"></td><td class="sign">-</td><td class="code">if a < b:</td></tr>
|
|
|
|
|
<tr class="add"><td class="num"></td><td class="num">2</td><td class="sign">+</td><td class="code">if a <= b:</td></tr>
|
|
|
|
|
<tr class="add"><td class="num"></td><td class="num">3</td><td class="sign">+</td><td class="code"> return "<b>"</td></tr>
|
|
|
|
|
</tbody>
|
|
|
|
|
<tbody><tr class="more"><td colspan="4">… 5 more lines not shown</td></tr></tbody>
|
|
|
|
|
</table>
|
|
|
|
|
</details>
|
|
|
|
|
<details class="file" id="f2" data-path="docs/new.md">
|
|
|
|
|
<summary>
|
|
|
|
|
<span class="badge renamed" title="renamed">R</span>
|
|
|
|
|
<span class="path"><span class="old">docs/old.md</span><span class="arrow"> → </span>docs/new.md</span>
|
|
|
|
|
<span class="tag">binary</span>
|
|
|
|
|
<span class="stat"><span class="add">+0</span><span class="del">−0</span></span>
|
|
|
|
|
</summary>
|
|
|
|
|
<p class="placeholder">No content changes.</p>
|
|
|
|
|
</details>
|
|
|
|
|
</section>
|
|
|
|
|
<section class="group" id="commits">
|
|
|
|
|
<details class="commit" id="c1" open><summary><code class="sha">abc1234</code></summary>
|
|
|
|
|
<div class="commit-files">
|
|
|
|
|
<details class="file" id="f3" data-path="skip.py"><summary><span class="badge added" title="added">A</span><span class="path">skip.py</span></summary></details>
|
|
|
|
|
</div>
|
|
|
|
|
</details>
|
|
|
|
|
</section>
|
|
|
|
|
</body></html>
|
|
|
|
|
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
class TestHubReviews(HubCase):
|
|
|
|
|
def decide(self, review_id: str, action: str, headers: dict[str, str], body: bytes) -> int:
|
|
|
|
|
status, _, _ = self.call("POST", f"/reviews/{review_id}/{action}", body, headers)
|
|
|
|
|
return status
|
|
|
|
|
|
|
|
|
|
def test_review_request_copies_the_page(self):
|
|
|
|
|
source = self.page("bundle.html", PAGE)
|
|
|
|
|
result = self.tool("review_request", self.arguments("feat: copy", "", source))
|
|
|
|
|
review_id = result["structuredContent"]["id"]
|
|
|
|
|
self.assertEqual(result["structuredContent"]["url"], f"http://localhost:{self.port}/")
|
|
|
|
|
Path(source).write_text("<p>changed after the request</p>", encoding="utf-8")
|
|
|
|
|
status, _, body = self.get(f"/reviews/{review_id}/page")
|
|
|
|
|
self.assertEqual((status, body.decode("utf-8")), (200, PAGE))
|
|
|
|
|
self.assertEqual((self.state / "pages" / f"{review_id}.html").read_text(encoding="utf-8"), PAGE)
|
|
|
|
|
|
|
|
|
|
def test_review_request_rejects_pages_outside_the_pages_dir(self):
|
|
|
|
|
evil = self.outside / "evil.html"
|
|
|
|
|
evil.write_text("<p>evil</p>", encoding="utf-8")
|
|
|
|
|
(self.pages / "notes.txt").write_text("plain", encoding="utf-8")
|
|
|
|
|
(self.pages / "link.html").symlink_to(evil)
|
|
|
|
|
(self.pages / "dir.html").mkdir()
|
|
|
|
|
os.mkfifo(self.pages / "fifo.html")
|
|
|
|
|
for page in (
|
|
|
|
|
str(evil),
|
|
|
|
|
f"{self.pages}/../outside/evil.html",
|
|
|
|
|
str(self.pages / "notes.txt"),
|
|
|
|
|
str(self.pages / "link.html"),
|
|
|
|
|
str(self.pages / "dir.html"),
|
|
|
|
|
str(self.pages / "fifo.html"),
|
|
|
|
|
str(self.pages / "missing.html"),
|
|
|
|
|
"bundle.html",
|
|
|
|
|
"",
|
|
|
|
|
):
|
|
|
|
|
with self.subTest(page=page):
|
|
|
|
|
result = self.tool("review_request", self.arguments("feat: x", "", page))
|
|
|
|
|
self.assertTrue(result["isError"], result)
|
|
|
|
|
self.assertEqual(self.tool("review_list", {})["structuredContent"]["reviews"], [])
|
|
|
|
|
self.assertEqual(list((self.state / "pages").iterdir()), [])
|
|
|
|
|
|
|
|
|
|
def test_approve_flips_the_status_and_review_wait_returns_it(self):
|
|
|
|
|
review_id = self.request("feat: approve me", "")
|
|
|
|
|
results = []
|
|
|
|
|
waiter = threading.Thread(target=lambda: results.append(self.tool("review_wait", {"id": review_id, "timeout_seconds": 30})))
|
|
|
|
|
waiter.start()
|
|
|
|
|
time.sleep(0.3)
|
|
|
|
|
self.assertTrue(waiter.is_alive())
|
|
|
|
|
signer = key(-7, "main")
|
|
|
|
|
for body in (b"", b'{"proof": null}', b'{"proof": {"payload": {}, "assertion": {}}}', b'{"proof": 1, "extra": 1}'):
|
|
|
|
|
with self.subTest(body=body):
|
|
|
|
|
self.assertEqual(self.decide(review_id, "approve", self.guard(self.host), body), 400)
|
|
|
|
|
self.assertTrue(waiter.is_alive())
|
|
|
|
|
started = time.monotonic()
|
|
|
|
|
proven = self.proven([self.entry("review", review_id, "", "")], signer)
|
|
|
|
|
self.assertEqual(self.decide(review_id, "approve", self.guard(self.host), proven), 200)
|
|
|
|
|
waiter.join(10)
|
|
|
|
|
self.assertLess(time.monotonic() - started, 10)
|
|
|
|
|
decided = results[0]["structuredContent"]
|
|
|
|
|
self.assertEqual((decided["status"], decided["tree"]), ("approved", TREE))
|
|
|
|
|
self.assertIsNotNone(decided["decided"])
|
|
|
|
|
self.assertEqual(self.status(review_id), "approved")
|
|
|
|
|
status, _, body = self.get(f"/api/reviews/{review_id}")
|
|
|
|
|
self.assertEqual((status, json.loads(body)["proof"]), (200, json.loads(proven)["proof"]))
|
|
|
|
|
self.assertEqual(self.decide(review_id, "reject", self.guard(self.host), b""), 409)
|
|
|
|
|
self.assertEqual(self.status(review_id), "approved")
|
|
|
|
|
other = self.request("feat: reject me", "")
|
|
|
|
|
self.assertEqual(self.decide(other, "reject", self.guard(f"localhost:{self.port}"), b""), 200)
|
|
|
|
|
self.assertEqual(self.tool("review_wait", {"id": other, "timeout_seconds": 0})["structuredContent"]["status"], "rejected")
|
|
|
|
|
self.assertIsNone(json.loads(self.get(f"/api/reviews/{other}")[2])["proof"])
|
|
|
|
|
self.assertEqual(self.tool("review_list", {})["structuredContent"]["reviews"], [])
|
|
|
|
|
self.assertEqual(self.decide("0123456789abcdef", "approve", self.guard(self.host), proven), 404)
|
|
|
|
|
self.assertEqual(self.get("/api/reviews/0123456789abcdef")[0], 404)
|
|
|
|
|
self.assertEqual(ledger.Reviews(self.state).get(review_id)["status"], "approved")
|
|
|
|
|
|
|
|
|
|
def test_review_wait_times_out_at_the_cap(self):
|
|
|
|
|
review_id = self.request("feat: slow", "")
|
|
|
|
|
started = time.monotonic()
|
|
|
|
|
with mock.patch.object(reviewing, "WAIT_CAP", 0.2):
|
|
|
|
|
result = self.tool("review_wait", {"id": review_id, "timeout_seconds": 10**6})
|
|
|
|
|
self.assertLess(time.monotonic() - started, 5)
|
|
|
|
|
self.assertEqual(result["structuredContent"]["status"], "pending")
|
|
|
|
|
|
|
|
|
|
def test_cross_site_decisions_are_forbidden(self):
|
|
|
|
|
review_id = self.request("feat: guarded", "")
|
|
|
|
|
good = self.guard(self.host)
|
|
|
|
|
for headers in (
|
2026-11-06 22:20:06 +00:00
|
|
|
{k: v for k, v in good.items() if k != "X-Kvb-Orchestrator"},
|
|
|
|
|
{**good, "X-Kvb-Orchestrator": "0"},
|
2026-11-06 08:28:04 +00:00
|
|
|
{k: v for k, v in good.items() if k != "Origin"},
|
|
|
|
|
{**good, "Origin": "http://evil.example"},
|
|
|
|
|
{**good, "Origin": "null"},
|
|
|
|
|
{**good, "Origin": f"http://localhost:{self.port}"},
|
|
|
|
|
{**good, "Host": f"evil.example:{self.port}", "Origin": f"http://evil.example:{self.port}"},
|
|
|
|
|
{**good, "Host": "127.0.0.1:1", "Origin": "http://127.0.0.1:1"},
|
|
|
|
|
):
|
|
|
|
|
for action in ("approve", "reject"):
|
|
|
|
|
with self.subTest(headers=headers, action=action):
|
|
|
|
|
self.assertEqual(self.decide(review_id, action, headers, b""), 403)
|
|
|
|
|
self.assertEqual(self.status(review_id), "pending")
|
|
|
|
|
|
|
|
|
|
def test_review_files_come_from_the_page(self):
|
|
|
|
|
files = review_page.review_files(REVIEW_PAGE.encode("utf-8"))
|
|
|
|
|
self.assertEqual([(f["section"], f["path"], f["old"], f["status"], f["added"], f["removed"]) for f in files], [
|
|
|
|
|
("staged", "hub/server.py", "", "modified", 2, 1),
|
|
|
|
|
("staged", "docs/new.md", "docs/old.md", "renamed", 0, 0),
|
|
|
|
|
])
|
|
|
|
|
lines = [(line["kind"], line["old"], line["new"], line["text"]) for line in files[0]["lines"]]
|
|
|
|
|
self.assertEqual(lines, [
|
|
|
|
|
("hunk", "", "", "@@ -1,2 +1,3 @@"),
|
|
|
|
|
("ctx", "1", "1", "import os"),
|
|
|
|
|
("del", "2", "", "if a < b:"),
|
|
|
|
|
("add", "", "2", "if a <= b:"),
|
|
|
|
|
("add", "", "3", ' return "<b>"'),
|
|
|
|
|
("more", "", "", "… 5 more lines not shown"),
|
|
|
|
|
])
|
|
|
|
|
self.assertEqual((files[1]["tags"], files[1]["detail"], files[1]["lines"]), (["binary"], "No content changes.", []))
|
|
|
|
|
review_id = self.tool("review_request", self.arguments("feat: tree", "", self.page("tree.html", REVIEW_PAGE)))["structuredContent"]["id"]
|
|
|
|
|
status, _, body = self.get(f"/reviews/{review_id}/files")
|
|
|
|
|
self.assertEqual((status, json.loads(body)), (200, {"files": files}))
|
|
|
|
|
self.assertEqual([(r["id"], r["files"]) for r in self.snapshot()["reviews"]], [(review_id, 2)])
|
|
|
|
|
self.assertEqual(self.get(f"/reviews/{review_id}/page")[2].decode("utf-8"), REVIEW_PAGE)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
if __name__ == "__main__":
|
|
|
|
|
unittest.main()
|