import re import unittest import xml.etree.ElementTree as ET from render_terminal import redact_line, render_svg class RenderTerminalTest(unittest.TestCase): def test_svg_is_valid_xml_and_escapes_output(self): svg = render_svg( "& value\nsecond", command="printf '& value'", cwd="/shared/codebase/demo", exit_code=0, executed_at="2026-08-28T06:00:00Z", ) ET.fromstring(svg) self.assertIn("<tag>& value", svg) self.assertNotIn("& value", svg) def test_metadata_is_rendered(self): svg = render_svg( "BUILD SUCCESSFUL", command="./gradlew test", cwd="/shared/codebase/demo", exit_code=0, executed_at="2026-08-28T06:00:00Z", ) self.assertIn("./gradlew test", svg) self.assertIn("/shared/codebase/demo", svg) self.assertIn("exit 0", svg) self.assertIn("2026-08-28T06:00:00Z", svg) def test_obvious_secrets_are_redacted(self): cases = { "Authorization: Bearer abc.def.ghi": "Authorization: Bearer [REDACTED]", "TOKEN=super-secret": "TOKEN=[REDACTED]", "PASSWORD=hunter2": "PASSWORD=[REDACTED]", "client_secret: abc123": "client_secret: [REDACTED]", "Cookie: SESSION=abcdef": "Cookie: [REDACTED]", } for raw, expected in cases.items(): with self.subTest(raw=raw): self.assertEqual(expected, redact_line(raw)) def test_credentials_inside_a_command_line_are_redacted(self): """줄 맨 앞이 아니라 명령 인자 안에 있는 자격증명. 터미널 증거에서 Bearer 가 가장 흔히 나오는 자리가 `curl -H` 의 인자다. 값은 닫는 따옴표 앞까지만 먹는다 — 넘겨 먹으면 명령의 나머지가 통째로 가려진다. 아래 값은 전부 합성이고 실제 비밀값이 아니다. """ cases = { 'curl -H "Authorization: Bearer TESTONLY-aaa.bbb.ccc" https://example.test/api': 'curl -H "Authorization: Bearer [REDACTED]" https://example.test/api', "curl -H 'Authorization: Bearer TESTONLY-xyz' -sS https://example.test": "curl -H 'Authorization: Bearer [REDACTED]' -sS https://example.test", 'curl -H "Cookie: SESSION=TESTONLY-sess" https://example.test/api': 'curl -H "Cookie: [REDACTED]" https://example.test/api', "Set-Cookie: SESSION=TESTONLY-x; HttpOnly": "Set-Cookie: [REDACTED]", } for raw, expected in cases.items(): with self.subTest(raw=raw): self.assertEqual(expected, redact_line(raw)) def test_connection_string_password_is_redacted_and_user_is_kept(self): """scheme://user:pw@host 의 암호만 가린다. 어느 계정으로 붙었는지는 증거의 일부라 사용자 이름을 남긴다. """ cases = { "psql postgresql://app:TESTONLY-pw@db:5432/app": "psql postgresql://app:[REDACTED]@db:5432/app", "DATABASE_URL=mysql://root:TESTONLY-pw@127.0.0.1:3306/app": "DATABASE_URL=mysql://root:[REDACTED]@127.0.0.1:3306/app", "redis://default:TESTONLY-pw@cache:6379/0": "redis://default:[REDACTED]@cache:6379/0", } for raw, expected in cases.items(): with self.subTest(raw=raw): self.assertEqual(expected, redact_line(raw)) def test_masking_does_not_eat_the_closing_quote(self): """가려졌다는 것과 명령이 그대로라는 것은 다르다. 값의 끝을 공백까지로 두면 닫는 따옴표까지 먹어 증거에 실린 명령이 실제로 돌린 명령과 달라진다. 가려진 것만 보고 지나치지 않도록 따옴표 수를 함께 센다. 아래 값은 전부 합성이고 실제 비밀값이 아니다. """ lines = ( 'curl -H "X-Api-Key: TESTONLY-i-apikey-header" https://example.invalid/d', 'curl -H "X-Token: TESTONLY-x" -H "Accept: application/json" https://example.invalid/d', 'export TOKEN="eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJURVNUT05MWSJ9.TESTONLYsig"', "curl -H 'X-Api-Key: TESTONLY-single' https://example.invalid/d", ) for raw in lines: with self.subTest(raw=raw): out = redact_line(raw) self.assertIn("[REDACTED]", out) self.assertNotIn("TESTONLY", out) self.assertEqual(raw.count('"'), out.count('"'), out) self.assertEqual(raw.count("'"), out.count("'"), out) def test_basic_auth_shapes_are_redacted(self): """Bearer 말고도 자격증명이 실리는 자리가 있다.""" cases = { 'curl -H "Authorization: Basic VEVTVE9OTFk6cHc=" https://example.invalid/d': 'curl -H "Authorization: Basic [REDACTED]" https://example.invalid/d', 'curl -H "Proxy-Authorization: Basic VEVTVE9OTFk6cHc=" https://example.invalid/d': 'curl -H "Proxy-Authorization: Basic [REDACTED]" https://example.invalid/d', "curl -u admin:TESTONLY-basic-pw https://example.invalid/d": "curl -u admin:[REDACTED] https://example.invalid/d", "curl --user admin:TESTONLY-basic-pw https://example.invalid/d": "curl --user admin:[REDACTED] https://example.invalid/d", } for raw, expected in cases.items(): with self.subTest(raw=raw): self.assertEqual(expected, redact_line(raw)) def test_a_bare_jwt_is_redacted(self): """`eyJ` 로 시작하는 점 두 개짜리 base64url 은 다른 것과 헷갈리지 않는다.""" raw = "Set token eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJURVNUT05MWSJ9.TESTONLYsig now" out = redact_line(raw) self.assertEqual("Set token [REDACTED] now", out) def test_ordinary_urls_are_not_touched(self): """자격증명이 없는 주소는 그대로 둔다. 과하게 가리면 증거를 못 읽는다.""" for line in ( "https://example.test/api?x=1", "git clone https://github.com/org/repo.git", "GET https://example.test/studio/documents/abc-123/edit -> 200", ): with self.subTest(line=line): self.assertEqual(line, redact_line(line)) def test_normal_output_is_not_changed_by_redaction(self): line = "GET /api/me -> 200 in 14ms" self.assertEqual(line, redact_line(line)) def test_truncation_marker_is_rendered_without_fabricating_hidden_lines(self): output = "\n".join(f"line-{i}" for i in range(8)) svg = render_svg( output, command="demo", cwd="/tmp", exit_code=1, executed_at="2026-08-28T06:00:00Z", max_lines=3, ) self.assertIn("line-0", svg) self.assertIn("line-2", svg) self.assertNotIn("line-3", svg) self.assertIn("[5 more lines omitted]", svg) def test_terminal_chrome_and_monospace_are_present(self): svg = render_svg( "ok", command="echo ok", cwd="/tmp", exit_code=0, executed_at="2026-08-28T06:00:00Z", ) self.assertGreaterEqual(len(re.findall(r"