mirror of
https://github.com/norandom/OpenUltraSAST.git
synced 2026-10-16 19:59:26 +00:00
99 source modules -> 81. 832 tests -> 646. The whole learning/ package (16 modules, ~4,100 lines) and six semantic/ modules removed, with the three gates byte-identical to 2026-09-06-gate-baseline.txt at every step. The sequence is what made it safe: vocabularies ported first (1.1), importers detached second (1.2-1.3), deletion third (1.4). Every import break during the deletion was therefore a detachment miss in the importer and was fixed there -- no module was restored to satisfy one. Both misses were the same shape and were fixed as a class: operations.py and shapes.py each imported trailing_name from the deleted variants.py, so it moved to semantic/ir.py, its natural home, and both importers changed in one edit. Ported before deleting: the closed guard vocabulary (verbatim, nothing else holds it) into model/specs.py, which derives TaintSpec and DominanceSpec from the retained ruleset facts rather than copying them. Re-homed: families -> model/taxonomy, candidates -> model/candidates, endpoint -> model/endpoint, plus Region, labeled_family and labeled_spans. labeled_family no longer falls back to the LLM classifier -- an unlabeled pair is honestly unknown. Four deviations, each recorded in the spec notes with its reason. improve/ evolve.py and validator.py were stripped, not deleted: each mixes the mechanism lever (gone) with the deterministic rule-status loop `ousast improve` still runs, which has no LLM in it; a test now asserts the lever's absence by symbol so it cannot creep back. The learning CLI group became `model candidates`, because deleting it outright would have orphaned the retained enumerator by removing its only entry point. thinking moved from [learning] to [models]. A gap named rather than papered over: no retained sink fact routes by CWE to path, output_encoding, untrusted_destination or prototype -- the same hole the entailment ceiling diagnosed. The test asserts the seeded set and the gap set exactly, so task 4.2 must update it when it authors those sinks. The audit found exactly one genuine orphan: vectorstore.py, a bakeoff harness with no production caller and no CLI, alive only through its own test. The five named subsystems each carry an explicit decision. Manifest committed, with a test asserting the tree still matches it. Group 2 (the Joern go/no-go) has not started. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01T7XD7x5KonMdmGRkp63EQG
460 lines
21 KiB
Python
460 lines
21 KiB
Python
"""Absence rows travel with the statements that register them (learning-harness 4.1, Req 10.2).
|
|
|
|
An access-control bug is the absence of a guard, so the excerpt has to carry the code that says the
|
|
handler is reachable at all: its decorators, the class its framework registers, and — when the
|
|
registration lives in another document — that document. Without it the entry-point mapper names no
|
|
handler, every obligation on the row is unreachable, and the pair scores as ``missing_context``
|
|
forever rather than as a detector miss.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import runpy
|
|
import shutil
|
|
import tempfile
|
|
import tomllib
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
|
|
from openultrasast.mapping import analyze_entry_points
|
|
from openultrasast.model.taxonomy import load_families
|
|
from openultrasast.pairs import DEFAULT_CATALOG, load_pair_catalog, select_vendored
|
|
from openultrasast.preprocess import preprocess_repository
|
|
from openultrasast.semantic.extra import has_semantic_extra
|
|
|
|
LIB = Path("benchmarks/pairs/harvest.py")
|
|
FAMILIES = Path("src/openultrasast/ruleset/families.toml")
|
|
|
|
|
|
def _lib() -> dict[str, object]:
|
|
return runpy.run_path(str(LIB), run_name="pair_harvest_lib")
|
|
|
|
|
|
# --- extraction --------------------------------------------------------------
|
|
|
|
|
|
RESTX_SOURCE = """from flask_restx import Resource
|
|
|
|
@ns.route('/profile')
|
|
class UserProfile(Resource):
|
|
@ns.expect(profile_query)
|
|
#@token_required
|
|
def get(self):
|
|
return dict(User.by_id(request.args['user_id'])), 200
|
|
|
|
@ns.route('/settings')
|
|
class Settings(Resource):
|
|
@token_required
|
|
def get(self, current_user):
|
|
return dict(current_user.settings), 200
|
|
"""
|
|
|
|
|
|
def test_handler_context_carries_the_class_its_framework_registers() -> None:
|
|
"""A flask-restx handler is a method: the route decorator sits on the class, not the function."""
|
|
lib = _lib()
|
|
excerpt = lib["extract_handler_context"](RESTX_SOURCE, "get", "python", line=7)
|
|
assert "@ns.route('/profile')" in excerpt
|
|
assert "class UserProfile(Resource):" in excerpt
|
|
assert "def get(self):" in excerpt
|
|
assert "class Settings" not in excerpt and "current_user.settings" not in excerpt
|
|
compile(excerpt, "<excerpt>", "exec") # a class fragment must still be importable Python
|
|
|
|
|
|
def test_handler_context_anchors_on_the_line_when_the_method_name_repeats() -> None:
|
|
"""Two `def get` in one file: the name alone cannot say which handler the row labels."""
|
|
lib = _lib()
|
|
second = lib["extract_handler_context"](RESTX_SOURCE, "get", "python", line=13)
|
|
assert "class Settings(Resource):" in second and "@token_required" in second
|
|
assert "UserProfile" not in second
|
|
|
|
|
|
def test_registration_context_returns_the_openapi_operation_stanza() -> None:
|
|
"""connexion registers by operationId in a separate document; that stanza is the registration."""
|
|
lib = _lib()
|
|
spec = (
|
|
"paths:\n"
|
|
" /users/v1/{username}/password:\n"
|
|
" put:\n"
|
|
" security:\n"
|
|
" - bearerAuth: [ ]\n"
|
|
" operationId: api_views.users.update_password\n"
|
|
" /books/v1/{book_title}:\n"
|
|
" get:\n"
|
|
" security:\n"
|
|
" - bearerAuth: [ ]\n"
|
|
" summary: Only the owner may retrieve it\n"
|
|
" operationId: api_views.books.get_by_title\n"
|
|
)
|
|
stanza = lib["extract_registration_context"](spec, "get_by_title", "yaml")
|
|
assert "/books/v1/{book_title}:" in stanza
|
|
assert "operationId: api_views.books.get_by_title" in stanza
|
|
assert "security:" in stanza and "Only the owner may retrieve it" in stanza, "the guard the route declares is the point"
|
|
assert "update_password" not in stanza and "/users/v1" not in stanza
|
|
|
|
|
|
def test_registration_context_returns_a_whole_multiline_router_call() -> None:
|
|
"""An express router registers over several lines; the middleware between them is the guard."""
|
|
lib = _lib()
|
|
router = (
|
|
"webRouter.post(\n"
|
|
" '/project/:project_id/rename',\n"
|
|
" AuthorizationMiddleware.ensureUserCanAdminProject,\n"
|
|
" ProjectController.renameProject\n"
|
|
")\n"
|
|
"webRouter.post(\n"
|
|
" '/project/:project_id/export/:brand_variation_id',\n"
|
|
" AuthorizationMiddleware.ensureUserCanWriteProjectContent,\n"
|
|
" ExportsController.exportProject\n"
|
|
")\n"
|
|
"// exportProject is named in a comment too\n"
|
|
)
|
|
call = lib["extract_registration_context"](router, "exportProject", "javascript")
|
|
assert "ExportsController.exportProject" in call
|
|
assert "ensureUserCanWriteProjectContent" in call
|
|
assert "renameProject" not in call and "named in a comment" not in call
|
|
|
|
|
|
def test_registration_context_is_empty_when_the_document_never_names_the_handler() -> None:
|
|
lib = _lib()
|
|
assert lib["extract_registration_context"]("paths:\n /x:\n get: {}\n", "get_by_title", "yaml") == ""
|
|
|
|
|
|
# --- harvest -----------------------------------------------------------------
|
|
|
|
|
|
CONTEXT_RECIPE = {
|
|
"name": "vampi-books-get-by-title",
|
|
"repo": "erev0s/VAmPI",
|
|
"parent": "p" * 40,
|
|
"commit": "p" * 40,
|
|
"path": "api_views/books.py",
|
|
"relpath": "api_views/books.py",
|
|
"mode": "handler_context",
|
|
"function": "get_by_title",
|
|
"language": "python",
|
|
"license": "MIT",
|
|
"context": ["openapi_specs/openapi3.yml"],
|
|
}
|
|
|
|
HANDLER_SOURCE = "def get_by_title(book_title):\n return Book.query.filter_by(book_title=book_title).first()\n"
|
|
SPEC_SOURCE = (
|
|
"paths:\n"
|
|
" /books/v1/{book_title}:\n"
|
|
" get:\n"
|
|
" security:\n"
|
|
" - bearerAuth: [ ]\n"
|
|
" operationId: api_views.books.get_by_title\n"
|
|
)
|
|
|
|
|
|
def _patch_fetch(lib: dict[str, object], bodies: dict[str, str], fail: frozenset[str] = frozenset()) -> None:
|
|
"""`runpy.run_path` hands back a copy of the globals, so the module's own name has to be rebound."""
|
|
|
|
def fetch(url: str, timeout: int = 60) -> str:
|
|
for fragment in fail:
|
|
if fragment in url:
|
|
raise OSError(f"fetch failed: {url}")
|
|
for fragment, body in bodies.items():
|
|
if fragment in url:
|
|
return body
|
|
raise AssertionError(f"unexpected url {url}")
|
|
|
|
lib["harvest_recipe"].__globals__["fetch_url"] = fetch # type: ignore[attr-defined]
|
|
|
|
|
|
def test_context_documents_are_harvested_for_both_sides(tmp_path: Path) -> None:
|
|
lib = _lib()
|
|
_patch_fetch(lib, {"books.py": HANDLER_SOURCE, "openapi3.yml": SPEC_SOURCE})
|
|
lib["harvest_recipe"](CONTEXT_RECIPE, tmp_path)
|
|
for side in ("vuln", "fixed"):
|
|
doc = tmp_path / "vampi-books-get-by-title" / "context" / side / "openapi_specs" / "openapi3.yml"
|
|
assert doc.is_file(), f"{side} context document missing"
|
|
text = doc.read_text()
|
|
assert "# license: MIT" in text, "context documents carry the licence line too"
|
|
assert "operationId: api_views.books.get_by_title" in text
|
|
assert lib["context_paths"](CONTEXT_RECIPE, tmp_path) == (
|
|
(
|
|
"openapi_specs/openapi3.yml",
|
|
tmp_path / "vampi-books-get-by-title" / "context" / "vuln" / "openapi_specs" / "openapi3.yml",
|
|
tmp_path / "vampi-books-get-by-title" / "context" / "fixed" / "openapi_specs" / "openapi3.yml",
|
|
),
|
|
)
|
|
|
|
|
|
def test_context_documents_are_redacted_like_every_other_excerpt(tmp_path: Path) -> None:
|
|
lib = _lib()
|
|
secret = "ghp_" + "A" * 30
|
|
_patch_fetch(
|
|
lib,
|
|
{"books.py": HANDLER_SOURCE, "openapi3.yml": SPEC_SOURCE + f" description: token {secret}\n x: get_by_title\n"},
|
|
)
|
|
lib["harvest_recipe"](CONTEXT_RECIPE, tmp_path)
|
|
text = (tmp_path / "vampi-books-get-by-title" / "context" / "vuln" / "openapi_specs" / "openapi3.yml").read_text()
|
|
assert secret not in text and "***REDACTED***" in text
|
|
|
|
|
|
def test_a_failed_fetch_keeps_the_old_excerpt_and_records_the_reason(tmp_path: Path) -> None:
|
|
"""A re-harvest that cannot reach the network must not truncate a reviewed excerpt."""
|
|
lib = _lib()
|
|
folder = tmp_path / "vampi-books-get-by-title"
|
|
folder.mkdir(parents=True)
|
|
(folder / "vuln.py").write_text("# reviewed excerpt\n")
|
|
(folder / "fixed.py").write_text("# reviewed excerpt\n")
|
|
_patch_fetch(lib, {"books.py": HANDLER_SOURCE}, fail=frozenset({"openapi3.yml"}))
|
|
with pytest.raises(OSError):
|
|
lib["harvest_recipe"](CONTEXT_RECIPE, tmp_path)
|
|
assert (folder / "vuln.py").read_text() == "# reviewed excerpt\n", "a failed fetch rewrote a reviewed excerpt"
|
|
|
|
|
|
def test_the_driver_records_every_failed_fetch_with_its_reason(tmp_path: Path) -> None:
|
|
lib = _lib()
|
|
recipes = tmp_path / "recipes.toml"
|
|
recipes.write_text(
|
|
"[[recipe]]\n"
|
|
'name = "vampi-books-get-by-title"\n'
|
|
'repo = "erev0s/VAmPI"\n'
|
|
f'parent = "{"p" * 40}"\n'
|
|
f'commit = "{"p" * 40}"\n'
|
|
'path = "api_views/books.py"\n'
|
|
'relpath = "api_views/books.py"\n'
|
|
'mode = "handler_context"\n'
|
|
'function = "get_by_title"\n'
|
|
'language = "python"\n'
|
|
'license = "MIT"\n'
|
|
'context = ["openapi_specs/openapi3.yml"]\n'
|
|
)
|
|
_patch_fetch(lib, {"books.py": HANDLER_SOURCE}, fail=frozenset({"openapi3.yml"}))
|
|
report = tmp_path / "report.json"
|
|
code = lib["main"](["--recipes", str(recipes), "--out", str(tmp_path), "--all", "--fetch", "--force", "--report", str(report)])
|
|
assert code == 1
|
|
import json
|
|
|
|
rows = json.loads(report.read_text())
|
|
assert [row["name"] for row in rows] == ["vampi-books-get-by-title"]
|
|
assert rows[0]["status"] == "failed" and "openapi3.yml" in rows[0]["reason"]
|
|
|
|
|
|
# --- the entry-point mapper --------------------------------------------------
|
|
|
|
|
|
def _entry_point_names(root: Path) -> set[str]:
|
|
_, targets = preprocess_repository(root)
|
|
return {record.function_name for record in analyze_entry_points(root, targets) if record.function_name}
|
|
|
|
|
|
def test_an_openapi_operation_id_names_its_python_handler(tmp_path: Path) -> None:
|
|
"""Req 10.2: a handler registered from a spec document is an entry point, not an orphan function."""
|
|
(tmp_path / "api_views").mkdir()
|
|
(tmp_path / "api_views" / "books.py").write_text(HANDLER_SOURCE)
|
|
(tmp_path / "openapi_specs").mkdir()
|
|
(tmp_path / "openapi_specs" / "openapi3.yml").write_text(SPEC_SOURCE)
|
|
_, targets = preprocess_repository(tmp_path)
|
|
records = analyze_entry_points(tmp_path, targets)
|
|
named = [record for record in records if record.function_name == "get_by_title"]
|
|
assert named, "the operationId did not name its handler"
|
|
assert named[0].path == "api_views/books.py", "the record belongs to the handler file, not the spec"
|
|
assert named[0].access_level == "authenticated"
|
|
assert any("bearerAuth" in item or "security" in item for item in named[0].access_evidence)
|
|
|
|
|
|
def test_an_openapi_operation_without_security_is_public(tmp_path: Path) -> None:
|
|
(tmp_path / "api_views").mkdir()
|
|
(tmp_path / "api_views" / "books.py").write_text(HANDLER_SOURCE)
|
|
(tmp_path / "spec.yml").write_text("paths:\n /books:\n get:\n operationId: api_views.books.get_by_title\n")
|
|
assert "get_by_title" in _entry_point_names(tmp_path)
|
|
_, targets = preprocess_repository(tmp_path)
|
|
record = next(item for item in analyze_entry_points(tmp_path, targets) if item.function_name == "get_by_title")
|
|
assert record.access_level == "public"
|
|
|
|
|
|
def test_a_document_without_operation_ids_adds_no_entry_point(tmp_path: Path) -> None:
|
|
(tmp_path / "api_views").mkdir()
|
|
(tmp_path / "api_views" / "books.py").write_text(HANDLER_SOURCE)
|
|
(tmp_path / "compose.yml").write_text("services:\n web:\n image: python\n")
|
|
assert _entry_point_names(tmp_path) == set()
|
|
|
|
|
|
def test_es_and_commonjs_modules_are_javascript(tmp_path: Path) -> None:
|
|
"""`.mjs` was not a known suffix, so the one pair vendored as an ES module had no file target at all: no entry
|
|
point, no finding, and a pair that could only ever score as a miss."""
|
|
(tmp_path / "controller.mjs").write_text("export function handler(req, res) {}\n")
|
|
(tmp_path / "legacy.cjs").write_text("module.exports = function handler(req, res) {}\n")
|
|
_, targets = preprocess_repository(tmp_path)
|
|
assert {target.path: target.language for target in targets} == {"controller.mjs": "javascript", "legacy.cjs": "javascript"}
|
|
|
|
|
|
def test_the_overleaf_pair_is_scannable_at_all() -> None:
|
|
case = next(
|
|
item for item in select_vendored(load_pair_catalog(DEFAULT_CATALOG)) if item.name == "overleaf-with-claude-exportscontroller-3980b9"
|
|
)
|
|
with tempfile.TemporaryDirectory() as scratch:
|
|
root = Path(scratch)
|
|
target = root / case.relpath
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copyfile(case.vuln_file, target)
|
|
_, targets = preprocess_repository(root)
|
|
assert [item.language for item in targets] == ["javascript"]
|
|
|
|
|
|
def test_a_multiline_router_call_names_its_handler(tmp_path: Path) -> None:
|
|
(tmp_path / "Exports").mkdir()
|
|
(tmp_path / "Exports" / "ExportsController.mjs").write_text(
|
|
"async function exportProject(req, res, next) {\n return res.send(req.params.project_id)\n}\nexport default { exportProject }\n"
|
|
)
|
|
(tmp_path / "router.mjs").write_text(
|
|
"webRouter.post(\n"
|
|
" '/project/:project_id/export/:brand_variation_id',\n"
|
|
" AuthorizationMiddleware.ensureUserCanWriteProjectContent,\n"
|
|
" ExportsController.exportProject\n"
|
|
")\n"
|
|
)
|
|
_, targets = preprocess_repository(tmp_path)
|
|
records = [item for item in analyze_entry_points(tmp_path, targets) if item.function_name == "exportProject"]
|
|
assert records, "the multi-line registration named no handler"
|
|
assert any(record.access_level == "authenticated" for record in records)
|
|
|
|
|
|
# --- the corpus --------------------------------------------------------------
|
|
|
|
|
|
def _absence_rows() -> tuple[object, ...]:
|
|
taxonomy = load_families(FAMILIES)
|
|
rows = []
|
|
for case in select_vendored(load_pair_catalog(DEFAULT_CATALOG)):
|
|
for expected in case.expected:
|
|
declared = getattr(expected, "family", None)
|
|
family = declared or (
|
|
(taxonomy.family_of_cwe(expected.cwe or "") or taxonomy.family_of_mechanism(expected.mechanism or "")) or None
|
|
)
|
|
resolved = family if isinstance(family, str) else getattr(family, "id", "")
|
|
if resolved:
|
|
if resolved == "access_control":
|
|
rows.append(case)
|
|
break
|
|
return tuple(rows)
|
|
|
|
|
|
def test_every_absence_row_declares_its_family() -> None:
|
|
"""Req 1: the family is the key that routes the work; an absence row must not depend on a CWE lookup."""
|
|
rows = _absence_rows()
|
|
assert len(rows) >= 10
|
|
undeclared = [case.name for case in rows if not any(getattr(row, "family", None) for row in case.expected)]
|
|
assert undeclared == [], f"absence rows with no declared family: {undeclared}"
|
|
|
|
|
|
def test_absence_recipes_use_the_handler_context_mode() -> None:
|
|
names = {case.name for case in _absence_rows()}
|
|
seen = set()
|
|
for slice_name in ("vibe-py", "agent-vfc"):
|
|
for recipe in tomllib.loads((Path("benchmarks/pairs") / slice_name / "recipes.toml").read_text())["recipe"]:
|
|
if str(recipe["name"]) in names:
|
|
seen.add(str(recipe["name"]))
|
|
assert recipe.get("mode") == "handler_context", f"{recipe['name']} is not harvested with its handler context"
|
|
assert recipe.get("family") == "access_control", f"{recipe['name']} carries no family"
|
|
assert seen == names, f"absence rows with no recipe: {sorted(names - seen)}"
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
("name", "registration"),
|
|
[
|
|
("vampi-books-get-by-title", "operationId: api_views.books.get_by_title"),
|
|
("vampi-users-update-password", "operationId: api_views.users.update_password"),
|
|
("threatbyte-api-v1-get", "@ns.route('/profile')"),
|
|
("threatbyte-api-v1-delete", "@ns.route('/delete-user/<int:user_id>')"),
|
|
("openniw-jobs-0fc947", '@router.get("/{job_id}")'),
|
|
("overleaf-with-claude-exportscontroller-3980b9", "ExportsController.exportProject"),
|
|
],
|
|
)
|
|
def test_the_named_absence_rows_carry_their_registration(name: str, registration: str) -> None:
|
|
"""The observable of task 4.1: these excerpts used to arrive without the line that makes them reachable."""
|
|
case = next(item for item in select_vendored(load_pair_catalog(DEFAULT_CATALOG)) if item.name == name)
|
|
texts = [case.vuln_file.read_text(), case.fixed_file.read_text()]
|
|
texts += [vuln.read_text() for _, vuln, _ in case.context_files]
|
|
assert any(registration in text for text in texts), f"{name} lost its registration"
|
|
assert all("license:" in text for text in [case.vuln_file.read_text(), case.fixed_file.read_text()])
|
|
|
|
|
|
def test_every_absence_row_has_a_named_handler_or_a_stated_reason() -> None:
|
|
"""Either the mapper names the labeled handler, or the row says out loud why it cannot be scored."""
|
|
orphans = []
|
|
for case in _absence_rows():
|
|
with tempfile.TemporaryDirectory() as scratch:
|
|
root = Path(scratch) / "vuln"
|
|
root.mkdir()
|
|
target = root / case.relpath
|
|
target.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copyfile(case.vuln_file, target)
|
|
for relpath, vuln, _fixed in case.context_files:
|
|
context = root / relpath
|
|
context.parent.mkdir(parents=True, exist_ok=True)
|
|
shutil.copyfile(vuln, context)
|
|
named = _entry_point_names(root)
|
|
labeled = {row.function for row in case.expected if row.function}
|
|
if not (labeled & named) and not case.unscorable:
|
|
orphans.append(f"{case.name} labeled={sorted(labeled)} named={sorted(named)}")
|
|
assert orphans == [], "absence rows with neither a named handler nor a stated reason:\n" + "\n".join(orphans)
|
|
|
|
|
|
def test_the_vendored_count_is_unchanged_by_the_re_harvest() -> None:
|
|
"""A re-harvest repairs excerpts; it must not quietly add or drop rows."""
|
|
cases = select_vendored(load_pair_catalog(DEFAULT_CATALOG))
|
|
per_slice: dict[str, int] = {}
|
|
for case in cases:
|
|
per_slice[case.slice] = per_slice.get(case.slice, 0) + 1
|
|
assert per_slice == {"agent-vfc": 29, "github": 6, "local": 3, "sast": 11, "vfc": 176, "vfc-js": 17, "vibe-py": 35}
|
|
|
|
|
|
def test_prose_inside_a_decorator_never_grants_a_guard(tmp_path: Path) -> None:
|
|
"""`@ns.doc(description='... without proper authorization checks')` describes the *bug*.
|
|
|
|
Matching auth words anywhere in a decorator, string literals included, reads an unguarded endpoint as guarded and
|
|
silences every obligation on it — the exact absence this corpus exists to catch."""
|
|
(tmp_path / "api.py").write_text(
|
|
"@ns.route('/delete-user/<int:user_id>')\n"
|
|
"@ns.doc(description='Delete a user without proper authorization checks. Broken Function Level Authorization.')\n"
|
|
"class UserDelete(Resource):\n"
|
|
" def delete(self, user_id):\n"
|
|
" return db.delete(user_id)\n\n"
|
|
"@ns.route('/settings')\n"
|
|
"class Settings(Resource):\n"
|
|
" @token_required\n"
|
|
" def get(self, current_user):\n"
|
|
" return current_user.settings\n"
|
|
)
|
|
_, targets = preprocess_repository(tmp_path)
|
|
access = {record.function_name: record.access_level for record in analyze_entry_points(tmp_path, targets) if record.function_name}
|
|
assert access == {"delete": "public", "get": "authenticated"}
|
|
|
|
|
|
def test_a_javascript_excerpt_never_starts_in_the_middle_of_an_import() -> None:
|
|
"""`_declarator_start` walks back to the previous `;` or `}`. In JavaScript written without semicolons the
|
|
nearest `}` is the one in `import { expressify } from ...`, so the excerpt began mid-statement and the file
|
|
did not parse at all — a pair that cannot be parsed cannot be scored."""
|
|
lib = _lib()
|
|
source = (
|
|
"import { expressify } from '@overleaf/promise-utils'\n"
|
|
"import SessionManager from '../Authentication/SessionManager.mjs'\n\n"
|
|
"async function exportProject(req, res, next) {\n"
|
|
" return res.send(req.params.project_id)\n"
|
|
"}\n"
|
|
)
|
|
excerpt = lib["extract_function"](source, "exportProject", "javascript")
|
|
assert excerpt.startswith("async function exportProject("), excerpt.splitlines()[0]
|
|
assert "from '@overleaf" not in excerpt
|
|
|
|
|
|
@pytest.mark.semantic
|
|
@pytest.mark.skipif(not has_semantic_extra(), reason="parsing JavaScript needs the tree-sitter grammar")
|
|
def test_the_overleaf_excerpt_parses_on_both_sides() -> None:
|
|
from openultrasast.semantic.ir import parse_file
|
|
|
|
case = next(
|
|
item for item in select_vendored(load_pair_catalog(DEFAULT_CATALOG)) if item.name == "overleaf-with-claude-exportscontroller-3980b9"
|
|
)
|
|
for path in (case.vuln_file, case.fixed_file):
|
|
ir = parse_file(case.relpath, path.read_text(), "javascript")
|
|
assert ir.parse_ok, f"{path.name}: {ir.reason}"
|
|
assert any(function.name == "exportProject" for function in ir.functions)
|