135 lines
6.8 KiB
Python
135 lines
6.8 KiB
Python
import copy
|
|
import json
|
|
import os
|
|
import subprocess
|
|
import sys
|
|
import tempfile
|
|
import unittest
|
|
from pathlib import Path
|
|
|
|
from harness.tests._support.paths import ROOT
|
|
from harness.tests._support.spec import MODPATH, mod, publication_fixture, result, verify_fixture
|
|
|
|
|
|
class ACClassificationTests(unittest.TestCase):
|
|
TEMPLATE = ROOT / 'harness/docs/requirements/TEMPLATE.md'
|
|
|
|
def body(self):
|
|
return ('## 7. 인수 기준\n\n### REQ-001.01-AC01 — example\n\n'
|
|
'**판정 기준**\n\nexample\n\n**검증**\n\nexample\n\n'
|
|
'**검증 분류**\n\n- 수준: unit\n- 백엔드: 없음\n- 케이스: normal\n\n'
|
|
'**상태 불변조건**\n\n없음\n\n## 8. 오류 및 충돌 계약\n\n없음\n')
|
|
|
|
def check(self, body):
|
|
import hashlib
|
|
self.assertTrue(callable(getattr(mod, 'check_ac_classification', None)),
|
|
'trusted-template AC classification checker missing')
|
|
return mod.check_ac_classification(body, hashlib.sha256(self.TEMPLATE.read_bytes()).hexdigest())
|
|
|
|
def test_valid_canonical_ac_classification(self):
|
|
out = self.check(self.body())
|
|
self.assertEqual(out['ac_ids'], ['REQ-001.01-AC01'])
|
|
self.assertEqual(out['checked_fields'], ['수준', '백엔드', '케이스'])
|
|
self.assertTrue(out['read_only'])
|
|
|
|
|
|
def test_missing_empty_duplicate_and_placeholder_fields_rejected_per_ac(self):
|
|
valid = self.body()
|
|
self.check(valid)
|
|
for changed in (valid.replace('- 수준: unit\n', ''),
|
|
valid.replace('- 백엔드: 없음', '- 백엔드: '),
|
|
valid.replace('- 케이스: normal', '- 케이스: ${verification_cases}'),
|
|
valid.replace('- 수준: unit', '- 수준: unit\n- 수준: unit'),
|
|
valid.replace('**검증 분류**', '**검증**'),
|
|
valid.replace('**검증 분류**', '**검증 분류**\n\n**검증 분류**')):
|
|
with self.subTest(changed=changed):
|
|
with self.assertRaises(mod.ValidationError):
|
|
self.check(changed)
|
|
|
|
def test_other_ac_and_fenced_examples_cannot_supply_missing_field(self):
|
|
good = self.body().split('## 8.', 1)[0]
|
|
bad = good.replace('AC01', 'AC02').replace('- 수준: unit\n', '')
|
|
self.check(good + good.replace('AC01', 'AC02').split('\n\n', 1)[1] + '## 8. 오류 및 충돌 계약\n')
|
|
for extra in ('', '```markdown\n- 수준: unit\n```\n', '~~~\n- 수준: unit\n~~~\n'):
|
|
with self.subTest(extra=extra):
|
|
with self.assertRaises(mod.ValidationError):
|
|
self.check(good + bad.split('\n\n', 1)[1].replace('**상태 불변조건**', extra + '**상태 불변조건**') + '## 8. 오류 및 충돌 계약\n')
|
|
|
|
|
|
def test_ambiguous_hidden_sections_and_template_drift_fail_closed(self):
|
|
valid = self.body()
|
|
self.check(valid)
|
|
import hashlib
|
|
with self.assertRaises(mod.ValidationError):
|
|
mod.check_ac_classification(valid, '0' * 64)
|
|
for bad in ('<!--\n' + valid + '\n-->', valid + '\n```unclosed',
|
|
valid.replace('### REQ', '#### REQ'),
|
|
valid.replace('## 8. 오류 및 충돌 계약', '## 7. 인수 기준'),
|
|
valid.replace('- 수준: unit', ' - 수준: unit')):
|
|
with self.subTest(bad=bad):
|
|
with self.assertRaises(mod.ValidationError):
|
|
self.check(bad)
|
|
crlf = valid.replace('\n', '\r\n')
|
|
self.assertEqual(self.check(crlf)['document_sha256'], hashlib.sha256(crlf.encode()).hexdigest())
|
|
|
|
def test_cli_read_only_exact_bytes_digest_and_sanitized_failure(self):
|
|
import hashlib
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
path = Path(tmp) / 'req.md'
|
|
raw = self.body().replace('\n', '\r\n').encode()
|
|
path.write_bytes(raw)
|
|
os.chmod(path, 0o600)
|
|
before = (path.read_bytes(), path.stat().st_mode, path.stat().st_mtime_ns, path.stat().st_ino)
|
|
argv = [sys.executable, '-B', str(MODPATH), 'check-ac-classification',
|
|
'--document', str(path), '--expected-document-sha256', hashlib.sha256(raw).hexdigest(),
|
|
'--expected-template-sha256', hashlib.sha256(self.TEMPLATE.read_bytes()).hexdigest()]
|
|
actual = subprocess.run(argv, capture_output=True, text=True, timeout=10)
|
|
self.assertEqual(actual.returncode, 0, actual.stderr)
|
|
self.assertEqual(json.loads(actual.stdout)['document_sha256'], hashlib.sha256(raw).hexdigest())
|
|
self.assertEqual(before, (path.read_bytes(), path.stat().st_mode, path.stat().st_mtime_ns, path.stat().st_ino))
|
|
argv[-3] = '0' * 64
|
|
bad = subprocess.run(argv, capture_output=True, text=True, timeout=10)
|
|
self.assertEqual(bad.returncode, 2)
|
|
self.assertEqual(json.loads(bad.stderr), {'error': 'invalid input'})
|
|
self.assertEqual(bad.stdout, '')
|
|
|
|
|
|
def test_supported_scope_rejects_hidden_html_and_noncanonical_ac_heading(self):
|
|
valid = self.body()
|
|
self.check(valid)
|
|
for bad in ('<div>\n' + valid + '</div>',
|
|
valid.replace('### REQ-001.01-AC01 — example', '### REQ-001.01-AC01'),
|
|
valid.replace('**검증 분류**', '<span>**검증 분류**</span>')):
|
|
with self.subTest(bad=bad):
|
|
with self.assertRaises(mod.ValidationError):
|
|
self.check(bad)
|
|
|
|
def test_file_admission_rejects_symlink_fifo_invalid_utf8_and_oversize(self):
|
|
with tempfile.TemporaryDirectory() as tmp:
|
|
root = Path(tmp)
|
|
valid = root / 'valid.md'
|
|
valid.write_text(self.body())
|
|
self.assertEqual(mod._classification_bytes(valid), valid.read_bytes())
|
|
alias = root / 'alias.md'
|
|
alias.symlink_to(valid)
|
|
fifo = root / 'fifo'
|
|
os.mkfifo(fifo)
|
|
invalid = root / 'invalid.md'
|
|
invalid.write_bytes(b'\xff')
|
|
large = root / 'large.md'
|
|
large.write_bytes(b'x' * 1048577)
|
|
for path in (alias, fifo, invalid, large, root):
|
|
with self.subTest(path=path.name):
|
|
with self.assertRaises(mod.ValidationError):
|
|
mod._classification_bytes(path)
|
|
|
|
def test_documentation_contract_scopes_checker_and_binds_digests(self):
|
|
text = (MODPATH.parent.parent / 'SKILL.md').read_text()
|
|
self.assertIn('### AC 검증 분류의 read-only 구조 검사', text)
|
|
section = text.split('### AC 검증 분류의 read-only 구조 검사', 1)[1].split('\n## ', 1)[0]
|
|
for rule in ('check-ac-classification', '--expected-document-sha256', '--expected-template-sha256'):
|
|
self.assertIn(rule, section)
|
|
|
|
if __name__ == "__main__":
|
|
unittest.main()
|