diff --git a/backend/scripts/export_openapi.py b/backend/scripts/export_openapi.py index 240b002d673..7d70b00b846 100644 --- a/backend/scripts/export_openapi.py +++ b/backend/scripts/export_openapi.py @@ -985,12 +985,26 @@ def write_spec(path: Path, generated: str) -> None: path.write_text(generated) -def check_spec(path: Path, generated: str) -> None: +def surface_flag(surface: str) -> str: + """Return the CLI flag that selects `surface`, so error hints regenerate the right contract. + + Surface selection is a separate flag from `--write`/`--check`; omitting it silently defaults to + the public surface and would overwrite a non-public spec with the wrong contract (#10217). + """ + if surface == 'app-client': + return '--app-client ' + if surface == 'integration-public': + return '--surface integration-public ' + return '' + + +def check_spec(path: Path, generated: str, surface: str = 'public') -> None: + flag = surface_flag(surface) if not path.exists(): - raise OpenAPIContractError(f'{path} does not exist; run export_openapi.py --write {path}') + raise OpenAPIContractError(f'{path} does not exist; run backend/scripts/export_openapi.py {flag}--write {path}') current = path.read_text() if current != generated: - raise OpenAPIContractError(f'{path} is stale; run backend/scripts/export_openapi.py --write {path}') + raise OpenAPIContractError(f'{path} is stale; run backend/scripts/export_openapi.py {flag}--write {path}') def parse_args() -> argparse.Namespace: @@ -1043,7 +1057,7 @@ def main() -> int: print(f'wrote {path}') elif args.check is not None: path = resolve_spec_path(args.surface, args.check) - check_spec(path, generated) + check_spec(path, generated, args.surface) print(f'{path} is up to date') return 0 except OpenAPIContractError as e: diff --git a/backend/tests/unit/test_openapi_contract.py b/backend/tests/unit/test_openapi_contract.py index 4b2187de519..832c9034d10 100644 --- a/backend/tests/unit/test_openapi_contract.py +++ b/backend/tests/unit/test_openapi_contract.py @@ -162,6 +162,28 @@ def test_check_spec_detects_stale_file(tmp_path): export_openapi.check_spec(spec_path, json.dumps({'fresh': True}) + '\n') +def test_check_spec_hint_includes_surface_flag(tmp_path): + # Regression for #10217: following the stale/missing hint verbatim must regenerate the same + # surface, not silently overwrite a non-public spec with the default public contract. + spec_path = tmp_path / 'openapi.json' + spec_path.write_text(json.dumps({'stale': True}) + '\n') + fresh = json.dumps({'fresh': True}) + '\n' + + with pytest.raises(export_openapi.OpenAPIContractError, match=r'--app-client --write'): + export_openapi.check_spec(spec_path, fresh, 'app-client') + + with pytest.raises(export_openapi.OpenAPIContractError, match=r'--surface integration-public --write'): + export_openapi.check_spec(spec_path, fresh, 'integration-public') + + missing = tmp_path / 'absent.json' + with pytest.raises(export_openapi.OpenAPIContractError, match=r'--app-client --write'): + export_openapi.check_spec(missing, fresh, 'app-client') + + # Public surface needs no flag — the default command stays correct. + with pytest.raises(export_openapi.OpenAPIContractError, match=r'export_openapi\.py --write'): + export_openapi.check_spec(spec_path, fresh) + + def test_network_recorder_fails_even_when_blocked_attempt_is_swallowed(): with export_openapi.record_and_block_outbound_network() as attempts: try: