queryapigate 0.34.2__tar.gz → 0.35.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {queryapigate-0.34.2/queryapigate.egg-info → queryapigate-0.35.1}/PKG-INFO +3 -1
- {queryapigate-0.34.2 → queryapigate-0.35.1}/README.md +2 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/__init__.py +1 -1
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/apikeys.py +4 -2
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/auditstream.py +1 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/console.py +6 -1
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/databases.py +2 -2
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/logging_setup.py +15 -1
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/quotas.py +28 -21
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/runners.py +4 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/sqltools.py +9 -1
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/store.py +4 -1
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/tableguard.py +44 -8
- {queryapigate-0.34.2 → queryapigate-0.35.1/queryapigate.egg-info}/PKG-INFO +3 -1
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_api_keys.py +6 -1
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_console.py +7 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_data_protection.py +21 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_dependency_floors.py +3 -2
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_integration.py +92 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_observability.py +16 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_queryapigate.py +10 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_release_check.py +12 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_sql_guard_fuzz.py +19 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_sql_guard_writes.py +15 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_supply_chain.py +41 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_tableguard.py +30 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/LICENSE +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/pyproject.toml +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/__main__.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/accesslog.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/adminroles.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/admins.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/agentwrites.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/alerts.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/app.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/auditchain.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/aws.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/broadcast.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/bundle.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/cache.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/cli.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/collection_admin.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/config.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/configcode.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/console_dist/assets/AccessMapPage-BVQ6wRwp.js +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/console_dist/assets/AdministratorsPage-BiUGhISO.js +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/console_dist/assets/AgentsPage-CSA-qCUv.js +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/console_dist/assets/AlertsPage-MYZoziNZ.js +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/console_dist/assets/ApiKeysPage-BZISE5oJ.js +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/console_dist/assets/ApprovalsPage-CUiEkHq3.js +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/console_dist/assets/AuditLogPage-D9vrSHU7.js +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/console_dist/assets/CachingPage-2hjur6KQ.js +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/console_dist/assets/CodeBox-BZY9RBv5.js +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/console_dist/assets/ConnectionsPage-D26k-98S.js +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/console_dist/assets/DesignerPage-B_os91xI.js +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/console_dist/assets/DestinationsPage--bjoNE0y.js +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/console_dist/assets/ExportsPage-5ai6Ojbl.js +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/console_dist/assets/HelpPage-DNaGeh-8.js +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/console_dist/assets/HomePage-DINN_eBD.js +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/console_dist/assets/Managed-CVE0layM.js +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/console_dist/assets/MetricsPage-D6UAGhYP.js +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/console_dist/assets/RepositoryPage-BvsZyKvp.js +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/console_dist/assets/Results-D8fDmPdY.js +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/console_dist/assets/RolesPage-BdEQdLFD.js +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/console_dist/assets/SchemaBrowser-Bu0NvoUy.js +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/console_dist/assets/SettingsPage-BQhT4xlX.js +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/console_dist/assets/StatTile-DEmSFOkX.js +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/console_dist/assets/Time-MA05YtZP.js +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/console_dist/assets/forms-BSqwVHNJ.js +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/console_dist/assets/forms-C6RLe1F3.js +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/console_dist/assets/icons-DQ8_pv9M.js +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/console_dist/assets/index-BLd0t6vZ.css +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/console_dist/assets/index-sn5NUET1.js +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/console_dist/assets/maskRules-BGQ7mS0j.js +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/console_dist/assets/reach-2gDU11-l.js +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/console_dist/assets/shared-D5ownoD-.js +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/console_dist/assets/state-O13cl7cO.js +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/console_dist/assets/useMutation-BF7AWw3p.js +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/console_dist/favicon.svg +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/console_dist/index.html +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/cors.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/db.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/definitions.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/deprecations.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/descriptions.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/destinations.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/duckfiles.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/engine.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/errors.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/events.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/examples.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/experimental.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/exporting.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/exports.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/formats.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/governance.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/gunicorn_conf.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/history.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/instances.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/jwtauth.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/lib/h2-2.2.224.jar +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/limits.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/masking.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/mcp_server.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/metrics.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/mongotools.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/oidc.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/openapi.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/params.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/parquet.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/pool.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/postman.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/ratelimit.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/rediscache.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/rekey.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/schema.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/secheaders.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/secretrefs.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/services/__init__.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/services/access.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/services/administrators.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/services/audit.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/services/collections.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/services/connections.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/services/descriptions.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/services/destinations.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/services/exports.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/services/mcp.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/services/queries.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/sqlflow.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/sso.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/tracing.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/usage.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/v1.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/v1_spec.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate/vault.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate.egg-info/SOURCES.txt +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate.egg-info/dependency_links.txt +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate.egg-info/entry_points.txt +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate.egg-info/requires.txt +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/queryapigate.egg-info/top_level.txt +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/setup.cfg +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_adhoc_history.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_adminroles.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_admins.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_agent_writes.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_alerts.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_api_administrators.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_api_v1.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_audit_chain.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_audit_log.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_audit_siem.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_audit_stream.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_aws.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_backup_restore.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_broadcast.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_cli.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_collection_bundle.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_collection_postman.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_collections.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_configcode.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_connection_encryption.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_cors_and_rate_limit.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_databases.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_db.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_deprecations.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_descriptions.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_destinations.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_docs_site.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_duckdb.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_duckdb_files.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_errors.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_events.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_events_server.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_examples.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_experimental.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_export_security.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_exporting.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_exports.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_fips.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_helm.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_history.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_howto.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_instances.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_jdbc.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_jwt.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_key_rotation.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_limits.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_masking.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_masking_requests.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_mcp.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_mcp_keycloak.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_mcp_oauth.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_mongotools.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_multinode.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_oidc.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_params.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_parquet.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_pool.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_publishing.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_query_window.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_quotas.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_rekey.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_response_cache.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_roles.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_rolling_upgrade.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_secretrefs.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_security_headers.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_settings.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_shared_rate_limit.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_sqlflow.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_sso.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_sso_keycloak.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_tracing.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_upgrades.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_usage.py +0 -0
- {queryapigate-0.34.2 → queryapigate-0.35.1}/tests/test_vault.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: queryapigate
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.35.1
|
|
4
4
|
Summary: Turn SQL into governed REST APIs over MySQL, PostgreSQL, ClickHouse, SQLite, DuckDB, H2, MongoDB, and Parquet/CSV/JSON files - JSON, CSV, Parquet or Excel out, with scoped keys, an admin Console and MCP for AI agents.
|
|
5
5
|
Author-email: Anantha Raju C <arcswdev@gmail.com>
|
|
6
6
|
License-Expression: FSL-1.1-MIT
|
|
@@ -84,6 +84,8 @@ Dynamic: license-file
|
|
|
84
84
|
[](https://pypi.org/project/queryapigate/)
|
|
85
85
|
[](https://github.com/AnanthaRajuC/QueryAPIGate/blob/main/LICENSE)
|
|
86
86
|

|
|
87
|
+
[](https://scorecard.dev/viewer/?uri=github.com/AnanthaRajuC/QueryAPIGate)
|
|
88
|
+
[](https://github.com/AnanthaRajuC/QueryAPIGate/blob/main/SECURITY.md#slsa-level)
|
|
87
89
|
|
|
88
90
|
### Turn SQL queries into secure, governed REST APIs.
|
|
89
91
|
|
|
@@ -6,6 +6,8 @@
|
|
|
6
6
|
[](https://pypi.org/project/queryapigate/)
|
|
7
7
|
[](https://github.com/AnanthaRajuC/QueryAPIGate/blob/main/LICENSE)
|
|
8
8
|

|
|
9
|
+
[](https://scorecard.dev/viewer/?uri=github.com/AnanthaRajuC/QueryAPIGate)
|
|
10
|
+
[](https://github.com/AnanthaRajuC/QueryAPIGate/blob/main/SECURITY.md#slsa-level)
|
|
9
11
|
|
|
10
12
|
### Turn SQL queries into secure, governed REST APIs.
|
|
11
13
|
|
|
@@ -450,8 +450,10 @@ def is_expired(entry):
|
|
|
450
450
|
return False
|
|
451
451
|
try:
|
|
452
452
|
deadline = datetime.strptime(expires_at, '%Y-%m-%d').replace(hour=23, minute=59, second=59)
|
|
453
|
-
except ValueError:
|
|
454
|
-
|
|
453
|
+
except (TypeError, ValueError):
|
|
454
|
+
# Every way in validates the date (_validate_expiry()), so this is a store edited by hand or damaged: an expiry
|
|
455
|
+
# nobody can read is treated as passed - fail closed - not as none (review, 2026-10-15)
|
|
456
|
+
return True
|
|
455
457
|
return datetime.now() > deadline
|
|
456
458
|
|
|
457
459
|
|
|
@@ -191,6 +191,7 @@ class _Syslog:
|
|
|
191
191
|
if parts.scheme != 'tls':
|
|
192
192
|
return raw
|
|
193
193
|
context = ssl.create_default_context(cafile=os.environ.get('QUERYAPIGATE_AUDIT_SYSLOG_CA', '').strip() or None)
|
|
194
|
+
context.minimum_version = ssl.TLSVersion.TLSv1_2 # the default; said, so nothing lowers it
|
|
194
195
|
cert = os.environ.get('QUERYAPIGATE_AUDIT_SYSLOG_CERT', '').strip()
|
|
195
196
|
if cert:
|
|
196
197
|
context.load_cert_chain(cert, os.environ.get('QUERYAPIGATE_AUDIT_SYSLOG_KEY', '').strip())
|
|
@@ -8,9 +8,11 @@ built it gets a short "not built" page instead of an error - backend work never
|
|
|
8
8
|
The page itself is public and holds no data: everything it shows comes from the JSON API, called with the API key the
|
|
9
9
|
user enters (kept in sessionStorage under the same name /docs uses).
|
|
10
10
|
"""
|
|
11
|
+
import os
|
|
11
12
|
from pathlib import Path
|
|
12
13
|
|
|
13
14
|
from flask import Response, send_from_directory
|
|
15
|
+
from werkzeug.security import safe_join
|
|
14
16
|
|
|
15
17
|
DIST = Path(__file__).parent / 'console_dist'
|
|
16
18
|
|
|
@@ -59,7 +61,10 @@ def serve(path=''):
|
|
|
59
61
|
asset reference fails visibly instead of loading HTML as a script."""
|
|
60
62
|
if not built():
|
|
61
63
|
return _secure(Response(NOT_BUILT_HTML, mimetype='text/html'))
|
|
62
|
-
|
|
64
|
+
# Only what's inside the build is looked at: checking the raw path told a caller whether a file existed anywhere
|
|
65
|
+
# on the server - 404 for one that did, the index for one that didn't (CodeQL py/path-injection).
|
|
66
|
+
inside = safe_join(str(DIST), path) if path else None
|
|
67
|
+
if inside is not None and os.path.isfile(inside):
|
|
63
68
|
response = send_from_directory(DIST, path, max_age=0)
|
|
64
69
|
if path.startswith('assets/'): # content-hashed file names: safe to cache forever
|
|
65
70
|
response.headers['Cache-Control'] = 'public, max-age=31536000, immutable'
|
|
@@ -15,8 +15,8 @@ NAMES = {'postgres': 'PostgreSQL', 'mysql': 'MySQL', 'sqlite': 'SQLite', 'duckdb
|
|
|
15
15
|
TIER_1 = tuple(t for t in TYPES if t not in experimental.DB_TYPES)
|
|
16
16
|
|
|
17
17
|
# Run against a real server (or, for SQLite, a real file) on every CI push: tests/test_integration.py's classes, and
|
|
18
|
-
# the main suite for SQLite.
|
|
19
|
-
INTEGRATION_TESTED = frozenset({'postgres', 'mysql', 'clickhouse', 'duckdb', 'h2', 'jdbc', 'sqlite'})
|
|
18
|
+
# the main suite for SQLite. CI fails if one skips.
|
|
19
|
+
INTEGRATION_TESTED = frozenset({'postgres', 'mysql', 'clickhouse', 'duckdb', 'h2', 'jdbc', 'sqlite', 'mongo'})
|
|
20
20
|
|
|
21
21
|
# Where the database itself refuses writes in a read-only session, beside QueryAPIGate's own SQL check (sqltools):
|
|
22
22
|
# MySQL and PostgreSQL read-only transactions, ClickHouse readonly=1, SQLite mode=ro. DuckDB, H2 and JDBC rest on
|
|
@@ -39,6 +39,20 @@ class _RequestContextFilter(logging.Filter):
|
|
|
39
39
|
return True
|
|
40
40
|
|
|
41
41
|
|
|
42
|
+
class _PlainFormatter(logging.Formatter):
|
|
43
|
+
"""One event, one line: a line break in the message - a caller's SQL, a request path with %0A in it - is written
|
|
44
|
+
as \\n, so it can't start what reads as a log line of its own (CodeQL py/log-injection). A traceback keeps its
|
|
45
|
+
lines: it comes after the message, from the server, not a caller."""
|
|
46
|
+
|
|
47
|
+
def formatMessage(self, record):
|
|
48
|
+
message = record.message
|
|
49
|
+
record.message = message.replace('\\', '\\\\').replace('\r', '\\r').replace('\n', '\\n')
|
|
50
|
+
try:
|
|
51
|
+
return super().formatMessage(record)
|
|
52
|
+
finally:
|
|
53
|
+
record.message = message
|
|
54
|
+
|
|
55
|
+
|
|
42
56
|
class _JsonFormatter(logging.Formatter):
|
|
43
57
|
def format(self, record):
|
|
44
58
|
payload = {
|
|
@@ -72,7 +86,7 @@ def configure(logger):
|
|
|
72
86
|
handler = logging.StreamHandler()
|
|
73
87
|
handler._queryapigate_managed = True
|
|
74
88
|
handler.addFilter(_RequestContextFilter())
|
|
75
|
-
handler.setFormatter(_JsonFormatter() if config.json_logs() else
|
|
89
|
+
handler.setFormatter(_JsonFormatter() if config.json_logs() else _PlainFormatter(_PLAIN_FORMAT))
|
|
76
90
|
logger.addHandler(handler)
|
|
77
91
|
logger.setLevel(logging.INFO)
|
|
78
92
|
logger.propagate = False
|
|
@@ -109,6 +109,10 @@ class _Counter:
|
|
|
109
109
|
|
|
110
110
|
|
|
111
111
|
_counter = _Counter()
|
|
112
|
+
# Held from taking the unwritten counts to storing them, and by forget(): a forget that ran while a write was in flight
|
|
113
|
+
# found the counts in neither place - taken from _counter, not yet in the store - and the write then stored them after
|
|
114
|
+
# the person was forgotten (CI, 2026-10-14). Reentrant: forget() writes first, under it.
|
|
115
|
+
_writing = threading.RLock()
|
|
112
116
|
|
|
113
117
|
|
|
114
118
|
class _Shared:
|
|
@@ -209,20 +213,21 @@ def _stored(caller, key):
|
|
|
209
213
|
def write():
|
|
210
214
|
"""Add what's been counted to the store(s) it was counted against - the history writer's batch calls this (and
|
|
211
215
|
tests). A failed write puts the counts back, to be written next time rather than lost."""
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
216
|
+
with _writing:
|
|
217
|
+
taken = _counter.take()
|
|
218
|
+
if not taken:
|
|
219
|
+
return
|
|
220
|
+
by_target = {}
|
|
221
|
+
for (target, caller, key), counts in taken.items():
|
|
222
|
+
by_target.setdefault(target, {})[(caller, key)] = counts
|
|
223
|
+
stamp = datetime.now().strftime(_TIME)
|
|
224
|
+
try:
|
|
225
|
+
for target, counted in by_target.items():
|
|
226
|
+
_write(target, counted, stamp)
|
|
227
|
+
except Exception:
|
|
228
|
+
_counter.put_back(taken)
|
|
229
|
+
raise
|
|
230
|
+
_counter.forget_reads()
|
|
226
231
|
|
|
227
232
|
|
|
228
233
|
def _write(target, counted, stamp):
|
|
@@ -249,13 +254,15 @@ def _ensure_written():
|
|
|
249
254
|
def forget(caller):
|
|
250
255
|
"""Delete a caller's usage counts - unwritten, stored and in Redis (ADR 0016: forgetting a person). Their quota's
|
|
251
256
|
count for the period starts again. Returns how many periods there were: what this process hadn't written yet is
|
|
252
|
-
written first, so the answer doesn't depend on whether a background write happened to come first
|
|
253
|
-
|
|
254
|
-
with
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
257
|
+
written first, so the answer doesn't depend on whether a background write happened to come first - or was in
|
|
258
|
+
flight: that one is waited for (_writing)."""
|
|
259
|
+
with _writing:
|
|
260
|
+
write()
|
|
261
|
+
with _counter.lock:
|
|
262
|
+
for slot in [s for s in _counter.read if s[0] == caller]:
|
|
263
|
+
del _counter.read[slot]
|
|
264
|
+
with db.transaction() as tx:
|
|
265
|
+
stored = tx.execute('DELETE FROM usage_counters WHERE caller = ?', (caller,)).rowcount
|
|
259
266
|
shared = _redis()
|
|
260
267
|
if shared is not None:
|
|
261
268
|
try:
|
|
@@ -546,6 +546,10 @@ class _H2(_Driver):
|
|
|
546
546
|
|
|
547
547
|
def connect(self, details, read_only):
|
|
548
548
|
import jaydebeapi
|
|
549
|
+
if not os.path.isfile(config.h2_jar()): # a source checkout: the driver is fetched, not committed
|
|
550
|
+
raise ApiError(f"H2's driver isn't installed ({config.h2_jar()}): in a source checkout run "
|
|
551
|
+
'python .github/scripts/fetch_h2_jar.py, or set QUERYAPIGATE_H2_JAR', 500,
|
|
552
|
+
code='driver_missing')
|
|
549
553
|
_attach_thread_as_daemon()
|
|
550
554
|
host = details.get('host') or 'localhost'
|
|
551
555
|
if details.get('port') and ':' not in host:
|
|
@@ -96,6 +96,10 @@ def write_operations(sql, dialect=None):
|
|
|
96
96
|
if keyword == 'explain':
|
|
97
97
|
inner = _EXPLAIN_RE.sub('', masked, count=1)
|
|
98
98
|
inner_keyword = first_keyword(inner, dialect)
|
|
99
|
+
if inner_keyword == 'explain':
|
|
100
|
+
# No database runs EXPLAIN EXPLAIN: refused as a write (fails closed) rather than looked into again - each
|
|
101
|
+
# level was a recursion re-scanning the whole text, so thousands of them raised RecursionError (fuzzing).
|
|
102
|
+
return {'explain'}
|
|
99
103
|
if inner_keyword in READ_ONLY_STATEMENTS:
|
|
100
104
|
return write_operations(inner, dialect)
|
|
101
105
|
return {inner_keyword} if inner_keyword in _WRITE_KEYWORDS else set()
|
|
@@ -144,7 +148,11 @@ def validate_sql(sql, dialect=None, allow_writes=None, allowed_write_ops=None, a
|
|
|
144
148
|
"""
|
|
145
149
|
if not isinstance(sql, str) or not sql.strip():
|
|
146
150
|
raise ApiError('SQL query is missing', code='sql_required')
|
|
147
|
-
sql =
|
|
151
|
+
sql = sql.strip()
|
|
152
|
+
end = len(sql) # trailing whitespace and semicolons, from the end: `[\s;]+$` was quadratic in a long inner run
|
|
153
|
+
while end and (sql[end - 1] == ';' or sql[end - 1].isspace()):
|
|
154
|
+
end -= 1
|
|
155
|
+
sql = sql[:end]
|
|
148
156
|
if ';' in _literals_re(dialect).sub(' ', sql):
|
|
149
157
|
raise ApiError('Only a single SQL statement can be executed at a time', code='multiple_statements')
|
|
150
158
|
# Every write the statement performs - including one hidden behind a read-only first keyword (a writable CTE,
|
|
@@ -8,6 +8,7 @@ filesystem path.
|
|
|
8
8
|
"""
|
|
9
9
|
import contextlib
|
|
10
10
|
import json
|
|
11
|
+
import logging
|
|
11
12
|
import os
|
|
12
13
|
import re
|
|
13
14
|
import threading
|
|
@@ -119,7 +120,9 @@ def record_audit(actor, action, target, changes=None, via=None):
|
|
|
119
120
|
# several instances still make one chain
|
|
120
121
|
link = auditchain.append(conn, entry, config.audit_log_limit())
|
|
121
122
|
except (OSError, *db.Error):
|
|
122
|
-
|
|
123
|
+
# The action stands, but it has no entry: said loudly, never silently (review, 2026-10-15)
|
|
124
|
+
logging.getLogger('queryapigate').error('Audit log: %s on %s by %s happened, but its entry could not be '
|
|
125
|
+
'written', action, target, actor, exc_info=True)
|
|
123
126
|
|
|
124
127
|
def deliver():
|
|
125
128
|
from . import auditstream
|
|
@@ -3,12 +3,13 @@ dialects sqlglot can parse (see ``_DIALECT_MAP``). Unlike sqlflow.py's best-effo
|
|
|
3
3
|
extraction for the Access tab's visualization, this one is a security check: a query whose tables can't be
|
|
4
4
|
verified - an unsupported dialect, a parse failure - must be rejected, not silently let through.
|
|
5
5
|
|
|
6
|
-
Extraction
|
|
7
|
-
``sqlglot.optimizer.scope.build_scope()`` -
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
CTE'
|
|
6
|
+
Extraction walks the parsed statement keeping track of which CTEs are visible where (``_real_tables``), not
|
|
7
|
+
``sqlglot.optimizer.scope.build_scope()`` - that returns None entirely for DELETE/UPDATE/INSERT (verified against a
|
|
8
|
+
real sqlglot install), which would silently miss a write statement's own target table. A bare name refers to a CTE
|
|
9
|
+
only where SQL says it does: in the statement after its WITH, in the CTEs defined after it - and in its own body
|
|
10
|
+
only under WITH RECURSIVE. Anywhere else, and always when qualified (``public.secret``), it's the table: dropping
|
|
11
|
+
every table that merely shared a CTE's name let `WITH secret AS (SELECT * FROM secret) SELECT * FROM secret` past a
|
|
12
|
+
key allowed only `orders` (fixed 2026-10-15).
|
|
12
13
|
|
|
13
14
|
Known, accepted limitations: a table name is extracted bare (``information_schema.tables`` -> ``'tables'``),
|
|
14
15
|
not schema-qualified - matches allowed_write_ops's own bare-keyword-list style, but can't disambiguate two
|
|
@@ -48,8 +49,43 @@ def extract_tables(sql, dialect):
|
|
|
48
49
|
name = function.sql_name().lower() if not isinstance(function, exp.Anonymous) else function.name.lower()
|
|
49
50
|
raise ApiError(f'This API key may only query its allowed tables - a table function such as {name}() '
|
|
50
51
|
"reads data the allow-list can't see, so it isn't allowed", 403, code='table_not_allowed')
|
|
51
|
-
|
|
52
|
-
|
|
52
|
+
try:
|
|
53
|
+
return _real_tables(parsed, frozenset())
|
|
54
|
+
except RecursionError: # nested deeper than can be walked: refused, never let through
|
|
55
|
+
raise ApiError('This query could not be analyzed to enforce its table access restrictions', 403,
|
|
56
|
+
code='table_check_failed') from None
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _real_tables(node, visible):
|
|
60
|
+
"""The tables under ``node`` that aren't references to a CTE in ``visible`` (lower-case names) - see the
|
|
61
|
+
module docstring for the scoping rule."""
|
|
62
|
+
from sqlglot import exp
|
|
63
|
+
if isinstance(node, exp.Table):
|
|
64
|
+
name = node.name.lower()
|
|
65
|
+
if not name or (name in visible and not node.args.get('db') and not node.args.get('catalog')):
|
|
66
|
+
return set()
|
|
67
|
+
return {name} | _children(node, visible)
|
|
68
|
+
found = set()
|
|
69
|
+
with_ = node.args.get('with') if isinstance(node, exp.Expression) else None
|
|
70
|
+
if isinstance(with_, exp.With):
|
|
71
|
+
ctes = [(cte.alias_or_name.lower(), cte.this) for cte in with_.expressions]
|
|
72
|
+
if with_.args.get('recursive'):
|
|
73
|
+
visible = visible | {name for name, _ in ctes}
|
|
74
|
+
for _, body in ctes:
|
|
75
|
+
found |= _real_tables(body, visible)
|
|
76
|
+
else:
|
|
77
|
+
for name, body in ctes: # each body sees the CTEs before it, not itself or those after
|
|
78
|
+
found |= _real_tables(body, visible)
|
|
79
|
+
visible = visible | {name}
|
|
80
|
+
return found | _children(node, visible, skip=with_)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def _children(node, visible, skip=None):
|
|
84
|
+
found = set()
|
|
85
|
+
for child in node.iter_expressions():
|
|
86
|
+
if child is not skip:
|
|
87
|
+
found |= _real_tables(child, visible)
|
|
88
|
+
return found
|
|
53
89
|
|
|
54
90
|
|
|
55
91
|
# Table functions that only generate values - no data to restrict - and so stay usable with allowed_tables.
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: queryapigate
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.35.1
|
|
4
4
|
Summary: Turn SQL into governed REST APIs over MySQL, PostgreSQL, ClickHouse, SQLite, DuckDB, H2, MongoDB, and Parquet/CSV/JSON files - JSON, CSV, Parquet or Excel out, with scoped keys, an admin Console and MCP for AI agents.
|
|
5
5
|
Author-email: Anantha Raju C <arcswdev@gmail.com>
|
|
6
6
|
License-Expression: FSL-1.1-MIT
|
|
@@ -84,6 +84,8 @@ Dynamic: license-file
|
|
|
84
84
|
[](https://pypi.org/project/queryapigate/)
|
|
85
85
|
[](https://github.com/AnanthaRajuC/QueryAPIGate/blob/main/LICENSE)
|
|
86
86
|

|
|
87
|
+
[](https://scorecard.dev/viewer/?uri=github.com/AnanthaRajuC/QueryAPIGate)
|
|
88
|
+
[](https://github.com/AnanthaRajuC/QueryAPIGate/blob/main/SECURITY.md#slsa-level)
|
|
87
89
|
|
|
88
90
|
### Turn SQL queries into secure, governed REST APIs.
|
|
89
91
|
|
|
@@ -949,6 +949,11 @@ class ApiKeysModuleTests(unittest.TestCase):
|
|
|
949
949
|
rate_limit=None, allowed_write_ops=None)
|
|
950
950
|
self.assertTrue(apikeys.can_write_query(admin, 'anything'))
|
|
951
951
|
|
|
952
|
+
def test_an_expiry_nobody_can_read_counts_as_passed(self):
|
|
953
|
+
for bad in ('not-a-date', '2026-13-40', 20261015):
|
|
954
|
+
with self.subTest(bad=bad):
|
|
955
|
+
self.assertTrue(apikeys.is_expired({'expires_at': bad}))
|
|
956
|
+
|
|
952
957
|
def test_is_expired(self):
|
|
953
958
|
yesterday = (datetime.now() - timedelta(days=1)).strftime('%Y-%m-%d')
|
|
954
959
|
tomorrow = (datetime.now() + timedelta(days=1)).strftime('%Y-%m-%d')
|
|
@@ -1042,4 +1047,4 @@ class CatalogTests(AppTestCase):
|
|
|
1042
1047
|
def test_requires_authentication_like_any_other_functional_endpoint(self):
|
|
1043
1048
|
self.assertEqual(self.client.get('/catalog').status_code, 401)
|
|
1044
1049
|
self.assertEqual(self.client.get('/catalog', headers={'X-API-Key': 'wrong'}).status_code, 401)
|
|
1045
|
-
self.
|
|
1050
|
+
self.assertTrue(apikeys.is_expired({'expires_at': 'garbage'})) # unreadable: fails closed (2026-10-15)
|
|
@@ -84,6 +84,13 @@ class BuiltTests(ConsoleTestCase):
|
|
|
84
84
|
res = self.get(path)
|
|
85
85
|
self.assertNotIn('do not serve', res.get_data(as_text=True), path)
|
|
86
86
|
|
|
87
|
+
def test_whether_a_file_outside_exists_isnt_told_either(self):
|
|
88
|
+
# CodeQL py/path-injection: the check before send_from_directory() looked outside the build, so a file that
|
|
89
|
+
# existed there answered 404 and one that didn't the Console's index - which file is on the server, told.
|
|
90
|
+
(Path(self.tmp.name) / 'LICENSE').write_text('outside')
|
|
91
|
+
there, missing = self.get('/console/%2e%2e/LICENSE'), self.get('/console/%2e%2e/NO_SUCH_FILE')
|
|
92
|
+
self.assertEqual((there.status_code, there.get_data()), (missing.status_code, missing.get_data()))
|
|
93
|
+
|
|
87
94
|
def test_every_response_carries_a_strict_content_security_policy(self):
|
|
88
95
|
for path in ('/console/', '/console/queries', '/console/assets/index-abc123.js',
|
|
89
96
|
'/console/assets/missing-999.js'):
|
|
@@ -147,6 +147,27 @@ class ForgetTests(unittest.TestCase):
|
|
|
147
147
|
quotas.write() # what was still unwritten was forgotten too
|
|
148
148
|
self.assertEqual(self.periods(self.PERSON), 0)
|
|
149
149
|
|
|
150
|
+
def test_a_write_in_flight_is_waited_for_not_missed(self):
|
|
151
|
+
# CI, 2026-10-14: the background writer had taken the unwritten month's count and was writing it when forget
|
|
152
|
+
# ran - forget found it in neither place, deleted the rest, and the writer then stored it after the person
|
|
153
|
+
# was forgotten. Forgetting now waits for a write in flight, and deletes what it wrote.
|
|
154
|
+
import threading
|
|
155
|
+
writing, release, original = threading.Event(), threading.Event(), quotas._write
|
|
156
|
+
|
|
157
|
+
def slow_write(target, counted, stamp):
|
|
158
|
+
writing.set()
|
|
159
|
+
release.wait(5)
|
|
160
|
+
original(target, counted, stamp)
|
|
161
|
+
with mock.patch.object(quotas, '_write', slow_write):
|
|
162
|
+
writer = threading.Thread(target=quotas.write)
|
|
163
|
+
writer.start()
|
|
164
|
+
self.assertTrue(writing.wait(5))
|
|
165
|
+
threading.Timer(0.3, release.set).start()
|
|
166
|
+
forgotten = history.forget(self.PERSON, 'owner')
|
|
167
|
+
writer.join(5)
|
|
168
|
+
self.assertEqual(forgotten['usage_periods'], 2)
|
|
169
|
+
self.assertEqual(self.periods(self.PERSON), 0)
|
|
170
|
+
|
|
150
171
|
def test_the_record_of_it_keeps_nothing_of_who(self):
|
|
151
172
|
history.forget(self.PERSON, 'owner')
|
|
152
173
|
entry = self.audit()[-1]
|
|
@@ -11,10 +11,11 @@ ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
|
11
11
|
|
|
12
12
|
# Each package whose floor CI tests, and the extra that declares it - None for a core dependency. Every core
|
|
13
13
|
# dependency is here, and every extra a stable feature needs (ADR 0017); the experimental features' extras (otel,
|
|
14
|
-
# aws,
|
|
14
|
+
# aws, h2) join when they graduate - mongo's already has, tested against a real server.
|
|
15
15
|
FLOORS = {'Flask': None, 'Werkzeug': None, 'PyYAML': None, 'openpyxl': None,
|
|
16
16
|
'duckdb': 'duckdb', 'sqlglot': 'flow', 'mcp': 'mcp', 'PyJWT': 'jwt', 'redis': 'redis',
|
|
17
|
-
'psycopg2-binary': 'postgres'
|
|
17
|
+
'psycopg2-binary': 'postgres', # the PostgreSQL store, and live events' notifications (ADR 0021)
|
|
18
|
+
'pymongo': 'mongo'}
|
|
18
19
|
|
|
19
20
|
|
|
20
21
|
def _project():
|
|
@@ -478,5 +478,97 @@ class DuckDBTests(IntegrationBase, unittest.TestCase):
|
|
|
478
478
|
self.assertEqual(len(self.query('SELECT * FROM queryapigate_it', '?page_size=100').get_json()), 7)
|
|
479
479
|
|
|
480
480
|
|
|
481
|
+
class MongoTests(unittest.TestCase):
|
|
482
|
+
"""MongoDB against a real server (QUERYAPIGATE_IT_MONGO): find() through /execute_mongo and saved queries, the
|
|
483
|
+
time limit, allowed_tables as collections, and the refusals that keep a find() a find() - each sent to the real
|
|
484
|
+
server's parser, not a mock's. Not IntegrationBase: there is no SQL to run."""
|
|
485
|
+
db, env_var = 'mongo', 'QUERYAPIGATE_IT_MONGO'
|
|
486
|
+
|
|
487
|
+
@classmethod
|
|
488
|
+
def setUpClass(cls):
|
|
489
|
+
raw = os.environ.get(cls.env_var)
|
|
490
|
+
if not raw:
|
|
491
|
+
raise unittest.SkipTest(f'{cls.env_var} not set')
|
|
492
|
+
cls.details = {**json.loads(raw), 'db': cls.db, 'active': True}
|
|
493
|
+
|
|
494
|
+
def setUp(self):
|
|
495
|
+
import pymongo
|
|
496
|
+
self.tmp = tempfile.TemporaryDirectory()
|
|
497
|
+
self.addCleanup(self.tmp.cleanup)
|
|
498
|
+
patcher = mock.patch.dict(os.environ, {'QUERYAPIGATE_HOME': self.tmp.name, 'QUERYAPIGATE_API_KEY': 'admin'})
|
|
499
|
+
patcher.start()
|
|
500
|
+
self.addCleanup(patcher.stop)
|
|
501
|
+
write_connections({'it': self.details})
|
|
502
|
+
self.client = create_app().test_client()
|
|
503
|
+
self.admin = {'X-API-Key': 'admin'}
|
|
504
|
+
server = pymongo.MongoClient(self.details['host'], int(self.details.get('port') or 27017))
|
|
505
|
+
self.addCleanup(server.close)
|
|
506
|
+
self.mongo = server[self.details['database']]
|
|
507
|
+
for name in ('queryapigate_it', 'queryapigate_it_secret', 'queryapigate_it_slow'):
|
|
508
|
+
self.mongo.drop_collection(name)
|
|
509
|
+
self.addCleanup(self.mongo.drop_collection, name)
|
|
510
|
+
self.mongo.queryapigate_it.insert_many([{'id': i, 'name': f'Item {i}', 'price': i + 0.5} for i in range(1, 8)])
|
|
511
|
+
self.mongo.queryapigate_it_secret.insert_one({'token': 'tok_live_123'})
|
|
512
|
+
|
|
513
|
+
def find(self, headers=None, query='', **body):
|
|
514
|
+
return self.client.post(f'/execute_mongo{query}', headers=headers or self.admin,
|
|
515
|
+
json={'connection_name': 'it', 'collection': 'queryapigate_it', **body})
|
|
516
|
+
|
|
517
|
+
def key(self, **grants):
|
|
518
|
+
res = self.client.post('/api/v1/api-keys', headers=self.admin, json={'name': 'partner', **grants})
|
|
519
|
+
self.assertEqual(res.status_code, 201, res.get_data(as_text=True))
|
|
520
|
+
return {'X-API-Key': res.get_json()['secret']}
|
|
521
|
+
|
|
522
|
+
def test_a_find_filters_projects_sorts_and_pages(self):
|
|
523
|
+
res = self.find(query='?page_size=2', filter={'price': {'$gte': 3}}, projection={'_id': 0, 'id': 1},
|
|
524
|
+
sort={'id': -1})
|
|
525
|
+
self.assertEqual(res.status_code, 200, res.get_data(as_text=True))
|
|
526
|
+
self.assertEqual(res.get_json(), [{'id': 7}, {'id': 6}])
|
|
527
|
+
self.assertEqual(res.headers['X-Has-More'], 'true')
|
|
528
|
+
|
|
529
|
+
def test_a_saved_query_binds_a_typed_parameter(self):
|
|
530
|
+
self.assertEqual(save_query(self.client, headers=self.admin, body={
|
|
531
|
+
'filename': 'by_id', 'author': 'a', 'description': 'd', 'query_type': 'mongo',
|
|
532
|
+
'mongo_collection': 'queryapigate_it', 'mongo_filter': {'id': ':id'},
|
|
533
|
+
'mongo_projection': {'_id': 0, 'name': 1}, 'query_parameters': {'id': 'int'},
|
|
534
|
+
'connection_name': 'it'}).status_code, 201)
|
|
535
|
+
res = self.client.get('/q/by_id?id=3', headers=self.admin)
|
|
536
|
+
self.assertEqual((res.status_code, res.get_json()), (200, [{'name': 'Item 3'}]))
|
|
537
|
+
|
|
538
|
+
def test_the_time_limit_cancels_a_find(self):
|
|
539
|
+
self.mongo.queryapigate_it_slow.insert_many([{'s': 'x' * 40 + str(i)} for i in range(100_000)])
|
|
540
|
+
res = self.find(collection='queryapigate_it_slow', filter={'s': {'$regex': 'y$'}}, timeout=0.001)
|
|
541
|
+
self.assertEqual((res.status_code, res.get_json()['code']), (504, 'query_timeout'))
|
|
542
|
+
unlimited = self.find(collection='queryapigate_it_slow', filter={'s': {'$regex': 'y$'}})
|
|
543
|
+
self.assertEqual(unlimited.status_code, 200) # the same find, given its time, simply finds nothing
|
|
544
|
+
|
|
545
|
+
def test_allowed_tables_are_the_collections_a_key_may_read(self):
|
|
546
|
+
partner = self.key(connections=['it'], allowed_tables=['queryapigate_it'])
|
|
547
|
+
self.assertEqual(self.find(partner).status_code, 200)
|
|
548
|
+
refused = self.find(partner, collection='queryapigate_it_secret')
|
|
549
|
+
self.assertEqual((refused.status_code, refused.get_json()['code']), (403, 'table_not_allowed'))
|
|
550
|
+
self.assertNotIn('tok_live', refused.get_data(as_text=True))
|
|
551
|
+
|
|
552
|
+
def test_server_side_javascript_and_injected_operators_never_reach_the_server(self):
|
|
553
|
+
js = {'$function': {'body': 'function() { return true; }', 'args': [], 'lang': 'js'}}
|
|
554
|
+
for body in ({'filter': {'$where': 'true'}}, {'filter': {'$expr': js}}, {'projection': {'x': js}},
|
|
555
|
+
{'sort': {'x': js}}):
|
|
556
|
+
with self.subTest(body=body):
|
|
557
|
+
res = self.find(**body)
|
|
558
|
+
self.assertEqual((res.status_code, res.get_json()['code']), (403, 'mongo_operator_forbidden'))
|
|
559
|
+
injected = self.find(filter={'name': ':u'}, params={'u': {'$ne': None}})
|
|
560
|
+
self.assertEqual((injected.status_code, injected.get_json()['code']), (400, 'param_invalid'))
|
|
561
|
+
|
|
562
|
+
def test_the_schema_lists_collections_and_the_server_its_databases(self):
|
|
563
|
+
res = self.client.get('/connections/it/schema', headers=self.admin)
|
|
564
|
+
self.assertEqual(res.status_code, 200, res.get_data(as_text=True))
|
|
565
|
+
self.assertIn('queryapigate_it', {t['name'] for t in res.get_json()['tables']})
|
|
566
|
+
listed = self.client.post('/api/v1/connections/databases', headers=self.admin, json=self.details)
|
|
567
|
+
self.assertEqual(listed.status_code, 200, listed.get_data(as_text=True))
|
|
568
|
+
self.assertIn(self.details['database'], json.dumps(listed.get_json()))
|
|
569
|
+
tested = self.client.post('/api/v1/connections/test', headers=self.admin, json=self.details)
|
|
570
|
+
self.assertEqual(tested.status_code, 200, tested.get_data(as_text=True))
|
|
571
|
+
|
|
572
|
+
|
|
481
573
|
if __name__ == '__main__':
|
|
482
574
|
unittest.main()
|
|
@@ -153,6 +153,22 @@ class LoggingFormatTests(unittest.TestCase):
|
|
|
153
153
|
_, handler = self._handler_for('queryapigate_test_plain', json_logs=False)
|
|
154
154
|
self.assertNotIsInstance(handler.formatter, logging_setup._JsonFormatter)
|
|
155
155
|
|
|
156
|
+
def test_a_line_break_in_a_message_cant_forge_a_line_of_its_own(self):
|
|
157
|
+
# CodeQL py/log-injection: a caller's SQL (or a request path with %0A) is in the message; in plain text a line
|
|
158
|
+
# break there could start what reads as another event. A traceback keeps its own lines.
|
|
159
|
+
logger, handler = self._handler_for('queryapigate_test_inject', json_logs=False)
|
|
160
|
+
buf = io.StringIO()
|
|
161
|
+
handler.stream = buf
|
|
162
|
+
logger.warning('Executing: %s', "SELECT 1\n2026-10-15 07:00:00 INFO queryapigate [- key=admin]: forged\\n")
|
|
163
|
+
try:
|
|
164
|
+
raise ValueError('boom')
|
|
165
|
+
except ValueError:
|
|
166
|
+
logger.exception('failed')
|
|
167
|
+
lines = buf.getvalue().splitlines()
|
|
168
|
+
self.assertTrue(lines[0].endswith(r"SELECT 1\n2026-10-15 07:00:00 INFO queryapigate [- key=admin]: forged\\n"))
|
|
169
|
+
self.assertFalse(any(line.startswith('2026-10-15 07:00:00 INFO') for line in lines))
|
|
170
|
+
self.assertIn('Traceback (most recent call last):', lines) # the server's own lines, as they were
|
|
171
|
+
|
|
156
172
|
def test_json_when_enabled(self):
|
|
157
173
|
logger, handler = self._handler_for('queryapigate_test_json', json_logs=True)
|
|
158
174
|
self.assertIsInstance(handler.formatter, logging_setup._JsonFormatter)
|
|
@@ -794,6 +794,7 @@ class DriverWiringTests(unittest.TestCase):
|
|
|
794
794
|
|
|
795
795
|
def test_h2_sets_query_timeout_and_maps_cancellation(self):
|
|
796
796
|
import jaydebeapi
|
|
797
|
+
self.enterContext(mock.patch.object(config, 'h2_jar', return_value=__file__)) # any file: the JVM is mocked
|
|
797
798
|
cursor = mock.MagicMock()
|
|
798
799
|
cursor.description = [('C',)]
|
|
799
800
|
cursor.execute.side_effect = [None, jaydebeapi.DatabaseError('Statement was canceled or the session timed out')]
|
|
@@ -1038,7 +1039,16 @@ class PooledDriverTests(unittest.TestCase):
|
|
|
1038
1039
|
with mock.patch('jpype.isJVMStarted', return_value=False): # nothing to attach to before the JVM starts
|
|
1039
1040
|
runners._attach_thread_as_daemon()
|
|
1040
1041
|
|
|
1042
|
+
def test_h2_without_its_driver_says_how_to_get_it(self):
|
|
1043
|
+
with mock.patch.object(config, 'h2_jar', return_value='/nowhere/h2.jar'), \
|
|
1044
|
+
mock.patch('jaydebeapi.connect') as connect, self.assertRaises(ApiError) as caught:
|
|
1045
|
+
runners._H2().connect({'host': 'h', 'database': 'd'}, True)
|
|
1046
|
+
self.assertEqual((caught.exception.status, caught.exception.code), (500, 'driver_missing'))
|
|
1047
|
+
self.assertIn('fetch_h2_jar.py', caught.exception.message)
|
|
1048
|
+
self.assertFalse(connect.called)
|
|
1049
|
+
|
|
1041
1050
|
def test_h2_dead_connection_is_replaced(self):
|
|
1051
|
+
self.enterContext(mock.patch.object(config, 'h2_jar', return_value=__file__)) # any file: the JVM is mocked
|
|
1042
1052
|
first, second = mock.MagicMock(), mock.MagicMock()
|
|
1043
1053
|
for conn in (first, second):
|
|
1044
1054
|
conn.cursor.return_value.description = [('C',)]
|
|
@@ -64,6 +64,18 @@ class ReleaseCheckTests(unittest.TestCase):
|
|
|
64
64
|
(self.root / 'CHANGELOG.md').unlink()
|
|
65
65
|
self.assertEqual(len(check_release.problems('v0.2.0', self.root)), 2)
|
|
66
66
|
|
|
67
|
+
def test_the_release_notes_are_the_versions_changelog_section(self):
|
|
68
|
+
(self.root / 'CHANGELOG.md').write_text(
|
|
69
|
+
'## [Unreleased]\n\n## [0.3.0] - 2026-10-14\n\nNewer.\n\n## [0.2.0] - 2026-09-21\n\nThe summary.\n\n'
|
|
70
|
+
'### Fixed\n- a thing\n\n[Unreleased]: https://example.com/compare\n')
|
|
71
|
+
self.assertEqual(check_release.notes('v0.2.0', self.root), 'The summary.\n\n### Fixed\n- a thing\n')
|
|
72
|
+
self.assertEqual(check_release.notes('v0.3.0', self.root), 'Newer.\n')
|
|
73
|
+
with self.assertRaises(SystemExit):
|
|
74
|
+
check_release.notes('v9.9.9', self.root)
|
|
75
|
+
out = subprocess.run(['python3', str(SCRIPT), '--notes', 'v0.3.0'], cwd=self.root, capture_output=True,
|
|
76
|
+
text=True)
|
|
77
|
+
self.assertEqual((out.returncode, out.stdout), (0, 'Newer.\n'))
|
|
78
|
+
|
|
67
79
|
def test_command_line_exit_codes(self):
|
|
68
80
|
run = lambda tag: subprocess.run(['python3', str(SCRIPT), tag], cwd=self.root, # noqa: E731
|
|
69
81
|
capture_output=True, text=True)
|
|
@@ -385,3 +385,22 @@ class PaginateFuzzTests(unittest.TestCase):
|
|
|
385
385
|
|
|
386
386
|
if __name__ == '__main__':
|
|
387
387
|
unittest.main()
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
class LinearTimeTests(unittest.TestCase):
|
|
391
|
+
"""CodeQL (py/polynomial-redos, 2026-10-15): validate_sql stripped trailing whitespace and semicolons with
|
|
392
|
+
`[\\s;]+$`, which a regex engine retries from every position of a long run that isn't at the end - 40,000 spaces
|
|
393
|
+
took three seconds, a few hundred thousand a minute, in any caller's ad-hoc SQL."""
|
|
394
|
+
|
|
395
|
+
def test_a_long_run_of_spaces_is_linear(self):
|
|
396
|
+
import time
|
|
397
|
+
started = time.monotonic()
|
|
398
|
+
sqltools.validate_sql('SELECT 1' + ' ' * 200_000 + 'AS x', 'postgres', allow_writes=False)
|
|
399
|
+
self.assertLess(time.monotonic() - started, 0.5)
|
|
400
|
+
|
|
401
|
+
def test_trailing_whitespace_and_semicolons_still_go(self):
|
|
402
|
+
for sql, expected in (('SELECT 1;', 'SELECT 1'), ('SELECT 1 ; ;\n\t ', 'SELECT 1'),
|
|
403
|
+
('SELECT 1 ;', 'SELECT 1'), (' SELECT 1', 'SELECT 1'),
|
|
404
|
+
("SELECT ';'", "SELECT ';'")):
|
|
405
|
+
with self.subTest(sql=sql):
|
|
406
|
+
self.assertEqual(sqltools.validate_sql(sql, 'postgres', allow_writes=False), expected)
|