291 lines
12 KiB
Python
291 lines
12 KiB
Python
import unittest
|
||
import urllib.parse
|
||
|
||
from _common import agent, jira_cfg, no_sleep
|
||
from fake_jira import FakeJira
|
||
|
||
|
||
class ClientsTest(unittest.TestCase):
|
||
def setUp(self):
|
||
self.fake = FakeJira()
|
||
self.jira = agent.JiraClient(jira_cfg(self.fake))
|
||
self.jira.sleep = no_sleep
|
||
|
||
def tearDown(self):
|
||
self.fake.stop()
|
||
|
||
def test_get_and_counters(self):
|
||
self.fake.add("AURORA-1")
|
||
data = self.jira.get("/rest/api/2/issue/AURORA-1", fields="summary")
|
||
self.assertEqual(data["fields"]["summary"], "Тема AURORA-1")
|
||
self.assertEqual(self.jira.calls, 1)
|
||
self.assertIsNotNone(self.jira.last_call_at)
|
||
self.assertEqual(self.fake.requests[-1], ("GET", "/rest/api/2/issue/AURORA-1"))
|
||
|
||
def test_multipart_file_bytes(self):
|
||
body = agent.multipart_file("b0", "Отчёт.txt", b"x\r\ny")
|
||
q = urllib.parse.quote("Отчёт.txt", safe="")
|
||
expected = ('--b0\r\nContent-Disposition: form-data; name="file"; filename="Отчёт.txt"; '
|
||
"filename*=UTF-8''%s\r\nContent-Type: application/octet-stream\r\n\r\n" % q).encode("utf-8") \
|
||
+ b"x\r\ny" + b"\r\n--b0--\r\n"
|
||
self.assertEqual(body, expected)
|
||
|
||
def test_wire_filename(self):
|
||
self.assertEqual(agent.wire_filename('а "б"\r\n.txt'), "а %22б%22%0D%0A.txt")
|
||
self.assertEqual(agent.wire_filename("ОСВ 60.xlsx"), "ОСВ 60.xlsx")
|
||
|
||
def test_upload_cyrillic_name(self):
|
||
self.fake.add("AURORA-1")
|
||
name = "ОСВ сч. 60 за 2 кв. 2026.xlsx"
|
||
data = bytes(range(256)) * 40
|
||
resp = self.jira.upload("AURORA-1", name, data)
|
||
self.assertIsInstance(resp, list)
|
||
a = self.fake.attachments["AURORA-1"][0]
|
||
self.assertEqual((resp[0]["id"], a["filename"], a["size"], a["data"]), (a["id"], name, len(data), data))
|
||
up = self.fake.uploads[-1]
|
||
self.assertEqual(up["token"], "no-check")
|
||
self.assertTrue(up["content_type"].startswith("multipart/form-data; boundary="))
|
||
self.assertEqual(len(up["content_type"].split("boundary=")[1]), 32) # uuid4().hex
|
||
self.assertEqual(up["headers"]["content-type"], "application/octet-stream")
|
||
self.assertEqual(up["disposition"]["filename*"], name)
|
||
self.assertEqual(self.jira.calls, 1)
|
||
self.assertEqual(self.fake.requests[-1], ("POST", "/rest/api/2/issue/AURORA-1/attachments"))
|
||
|
||
def test_upload_filename_with_quote_and_newline(self):
|
||
self.fake.add("AURORA-1")
|
||
name = 'итог "кв.2"\r\n.txt'
|
||
self.jira.upload("AURORA-1", name, b"abc")
|
||
up = self.fake.uploads[-1]
|
||
self.assertEqual(up["disposition"]["filename"], "итог %22кв.2%22%0D%0A.txt")
|
||
self.assertEqual(up["disposition"]["filename*"], name)
|
||
self.assertEqual(len(self.fake.attachments["AURORA-1"]), 1)
|
||
self.assertEqual(self.fake.attachments["AURORA-1"][0]["data"], b"abc")
|
||
|
||
def test_upload_needs_xsrf_header(self):
|
||
self.fake.add("AURORA-1")
|
||
self.jira.headers = {k: v for k, v in self.jira.headers.items() if k != "X-Atlassian-Token"}
|
||
with self.assertRaises(agent.HttpError) as cm:
|
||
self.jira.upload("AURORA-1", "a.txt", b"abc")
|
||
self.assertEqual(cm.exception.code, 403)
|
||
self.assertNotIn("AURORA-1", self.fake.attachments)
|
||
|
||
def test_upload_retries_429(self):
|
||
self.fake.add("AURORA-1")
|
||
self.fake.fail_next.append((429, {"Retry-After": "0"}))
|
||
self.jira.upload("AURORA-1", "a.txt", b"abc")
|
||
self.assertEqual(len(self.fake.attachments["AURORA-1"]), 1)
|
||
self.assertEqual(self.jira.rate_limited, 1)
|
||
self.assertEqual(self.jira.calls, 2)
|
||
|
||
def test_upload_timeout_at_least_120(self):
|
||
self.fake.add("AURORA-1")
|
||
seen = []
|
||
orig = self.jira.send
|
||
|
||
def spy(*a, **kw):
|
||
seen.append(kw.get("timeout"))
|
||
return orig(*a, **kw)
|
||
|
||
self.jira.send = spy
|
||
self.jira.upload("AURORA-1", "a.txt", b"abc")
|
||
self.assertEqual(seen, [120]) # timeout в конфиге тестов — 5 с
|
||
|
||
def test_retry_on_429_with_retry_after(self):
|
||
self.fake.add("AURORA-1")
|
||
self.fake.fail_next.append((429, {"Retry-After": "0"}))
|
||
self.fake.fail_next.append((503, {}))
|
||
data = self.jira.get("/rest/api/2/issue/AURORA-1")
|
||
self.assertEqual(data["key"], "AURORA-1")
|
||
self.assertEqual(self.jira.rate_limited, 2)
|
||
self.assertEqual(self.jira.calls, 3) # считаются все полученные ответы, включая 429/503
|
||
|
||
def test_http_error_not_retried(self):
|
||
with self.assertRaises(agent.HttpError) as cm:
|
||
self.jira.get("/rest/api/2/issue/NOPE-1")
|
||
self.assertEqual(cm.exception.code, 404)
|
||
self.assertIn("Does Not Exist", cm.exception.text)
|
||
self.assertEqual(self.jira.calls, 1)
|
||
self.assertIsNotNone(self.jira.last_call_at)
|
||
|
||
def test_gives_up_after_five(self):
|
||
self.fake.add("AURORA-1")
|
||
self.fake.fail_next.extend([(429, {"Retry-After": "0"})] * 5)
|
||
with self.assertRaises(agent.HttpError):
|
||
self.jira.get("/rest/api/2/issue/AURORA-1")
|
||
self.assertEqual(self.jira.rate_limited, 5)
|
||
self.assertEqual(self.jira.calls, 5)
|
||
|
||
def test_basic_auth_header_and_no_secret_in_repr(self):
|
||
self.assertTrue(self.jira.headers["Authorization"].startswith("Basic "))
|
||
self.assertNotIn("secret", repr(self.jira))
|
||
|
||
def test_retry_after_lowercase_header(self):
|
||
waits = []
|
||
self.jira.sleep = waits.append
|
||
self.fake.add("AURORA-1")
|
||
self.fake.fail_next.append((429, {"retry-after": "7"}))
|
||
self.jira.get("/rest/api/2/issue/AURORA-1")
|
||
self.assertEqual(waits, [7])
|
||
|
||
def test_retry_after_not_a_number(self):
|
||
waits = []
|
||
self.jira.sleep = waits.append
|
||
self.fake.add("AURORA-1")
|
||
self.fake.fail_next.append((503, {"Retry-After": "Wed, 21 Oct 2026 07:28:00 GMT"}))
|
||
self.jira.get("/rest/api/2/issue/AURORA-1")
|
||
self.assertEqual(waits, [60])
|
||
|
||
def test_retry_after_negative(self):
|
||
waits = []
|
||
self.jira.sleep = waits.append
|
||
self.fake.add("AURORA-1")
|
||
self.fake.fail_next.append((429, {"Retry-After": "-5"}))
|
||
self.jira.get("/rest/api/2/issue/AURORA-1")
|
||
self.assertEqual(waits, [0])
|
||
|
||
def test_error_body_read_failure_is_connection_error(self):
|
||
import io
|
||
import urllib.error
|
||
from unittest import mock
|
||
|
||
class Broken(io.BytesIO):
|
||
def read(self, *a):
|
||
raise TimeoutError("timed out")
|
||
|
||
err = urllib.error.HTTPError("http://x", 500, "boom", {}, Broken())
|
||
with mock.patch("urllib.request.urlopen", side_effect=err):
|
||
with self.assertRaises(ConnectionError):
|
||
self.jira.get("/x")
|
||
self.assertEqual(self.jira.calls, 1)
|
||
|
||
def test_incomplete_read_is_connection_error(self):
|
||
import http.client
|
||
from unittest import mock
|
||
with mock.patch("urllib.request.urlopen", side_effect=http.client.IncompleteRead(b"ab", 10)):
|
||
with self.assertRaises(ConnectionError):
|
||
self.jira.get("/x")
|
||
|
||
def test_incomplete_read_inside_response_body(self):
|
||
import socket
|
||
import threading
|
||
srv = socket.socket()
|
||
srv.bind(("127.0.0.1", 0))
|
||
srv.listen(1)
|
||
|
||
def serve():
|
||
c, _ = srv.accept()
|
||
c.recv(4096)
|
||
c.sendall(b"HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: 100\r\n\r\n{\"a\":")
|
||
c.close()
|
||
|
||
t = threading.Thread(target=serve, daemon=True)
|
||
t.start()
|
||
cfg = jira_cfg(self.fake)
|
||
cfg["jira"]["base_url"] = "http://127.0.0.1:%d" % srv.getsockname()[1]
|
||
try:
|
||
with self.assertRaises(ConnectionError):
|
||
agent.JiraClient(cfg).get("/x")
|
||
finally:
|
||
t.join(2)
|
||
srv.close()
|
||
|
||
def test_bearer_header(self):
|
||
cfg = jira_cfg(self.fake)
|
||
cfg["jira"]["auth"] = {"type": "bearer", "token": "tok123"}
|
||
self.assertEqual(agent.JiraClient(cfg).headers["Authorization"], "Bearer tok123")
|
||
|
||
def test_unreachable_jira_raises_connection_error(self):
|
||
cfg = jira_cfg(self.fake)
|
||
cfg["jira"]["base_url"] = "http://127.0.0.1:1"
|
||
with self.assertRaises(ConnectionError):
|
||
agent.JiraClient(cfg).get("/rest/api/2/myself")
|
||
|
||
def test_non_json_200_becomes_http_error(self):
|
||
import threading
|
||
from http.server import BaseHTTPRequestHandler, HTTPServer
|
||
|
||
class H(BaseHTTPRequestHandler):
|
||
def log_message(self, *a):
|
||
pass
|
||
|
||
def do_GET(self):
|
||
body = b"<html>\xff\xfe not json"
|
||
self.send_response(200)
|
||
self.send_header("Content-Length", str(len(body)))
|
||
self.end_headers()
|
||
self.wfile.write(body)
|
||
|
||
srv = HTTPServer(("127.0.0.1", 0), H)
|
||
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
||
try:
|
||
cfg = jira_cfg(self.fake)
|
||
cfg["jira"]["base_url"] = "http://127.0.0.1:%d" % srv.server_port
|
||
jira = agent.JiraClient(cfg)
|
||
with self.assertRaises(agent.HttpError) as cm:
|
||
jira.get("/x")
|
||
self.assertEqual(cm.exception.code, 200)
|
||
self.assertEqual(jira.calls, 1)
|
||
finally:
|
||
srv.shutdown()
|
||
srv.server_close()
|
||
|
||
def test_secret_not_in_log_output(self):
|
||
import io
|
||
from contextlib import redirect_stdout
|
||
buf = io.StringIO()
|
||
self.fake.add("AURORA-1")
|
||
self.fake.fail_next.append((429, {"Retry-After": "0"}))
|
||
with redirect_stdout(buf):
|
||
self.jira.get("/rest/api/2/issue/AURORA-1")
|
||
agent.log("проверка")
|
||
out = buf.getvalue()
|
||
self.assertNotIn("secret", out)
|
||
self.assertNotIn(self.jira.headers["Authorization"], out)
|
||
|
||
def test_load_config_requires_fields(self):
|
||
import json
|
||
import os
|
||
import tempfile
|
||
with tempfile.TemporaryDirectory() as d:
|
||
path = os.path.join(d, "agent.json")
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
json.dump({"hub_url": "http://x", "hub_token": "t"}, f)
|
||
with self.assertRaises(SystemExit):
|
||
agent.load_config(path)
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
json.dump(jira_cfg(self.fake), f)
|
||
cfg = agent.load_config(path)
|
||
self.assertEqual(cfg["poll_seconds"], 0)
|
||
|
||
def test_check_command(self):
|
||
import json
|
||
import os
|
||
import tempfile
|
||
with tempfile.TemporaryDirectory() as d:
|
||
path = os.path.join(d, "agent.json")
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
json.dump(jira_cfg(self.fake), f)
|
||
# хаб недоступен (порт 1), Jira доступен — check печатает оба результата и возвращает 1
|
||
self.assertEqual(agent.main(["--config", path, "check"]), 1)
|
||
|
||
|
||
def test_selftest_command(self):
|
||
import contextlib
|
||
import io
|
||
import json
|
||
import os
|
||
import tempfile
|
||
with tempfile.TemporaryDirectory() as d:
|
||
path = os.path.join(d, "agent.json")
|
||
with open(path, "w", encoding="utf-8") as f:
|
||
json.dump(jira_cfg(self.fake), f)
|
||
out = io.StringIO()
|
||
with contextlib.redirect_stdout(out):
|
||
# хаб недоступен (порт 1), Jira доступен — selftest печатает версию и возвращает 3, а не 1
|
||
self.assertEqual(agent.main(["--config", path, "selftest"]), 3)
|
||
self.assertIn("jira-agent v%d" % agent.AGENT_VERSION, out.getvalue())
|
||
self.assertIn("Jira: доступен", out.getvalue())
|
||
|
||
if __name__ == "__main__":
|
||
unittest.main()
|