fleet-memory/hindsight-api-slim/tests/test_sql_schema_safety.py
Nicolò Boschi 15ea23d5d6
feat: introduce hindsight-api-slim and hindsight-all-slim packages (#560)
* feat: introduce hindsight-api-slim and hindsight-all-slim packages

Closes #552

- Move all source code from hindsight-api/ to new hindsight-api-slim/
- hindsight-api-slim has heavy ML deps (torch, sentence-transformers,
  transformers, einops, flashrank, mlx, mlx-lm, safetensors) and
  pg0-embedded as optional extras: [local-ml], [embedded-db], [all]
- hindsight-api becomes a zero-code meta-package depending on
  hindsight-api-slim[all] for full backward compatibility
- Add hindsight-all-slim meta-package: hindsight-api-slim + client + embed
- hindsight-all updated to depend on hindsight-api-slim[all]
- pg0.py: lazy-import pg0 with clear ImportError pointing to [embedded-db]
- Dockerfile: replace sed hack with proper uv sync --extra flags
- Update release.yml, test.yml, lint.sh, release.sh, CLAUDE.md and
  all path references throughout the repo

* refactor: rename hindsight/ directory to hindsight-all/

* docs: document hindsight-api-slim and hindsight-all-slim package variants

Add package variants table and extras explanation to installation.md

* docs: remove emojis from installation.md, use professional tone

* docs: link Docker slim variant to pip package variants section

* docs: consolidate Docker image variants into single table

* ci: fix working-directory paths after package restructure

- Replace all hindsight-api → hindsight-api-slim in test.yml
- Replace hindsight → hindsight-all in test.yml
- Add --extra embedded-db to test-embed API install step

* ci: add local-ml and embedded-db extras to API sync steps

These extras were previously implicit in the old hindsight-api package
(which bundled everything). Now that hindsight-api-slim uses optional
extras, we must explicitly request local-ml and embedded-db in CI.

* ci: add API install step with embedded-db to test-embed smoke test

The smoke test starts hindsight-api as a daemon, which requires pg0-embedded.
Add a dedicated install step for hindsight-api-slim with embedded-db extra
so the daemon can start successfully.

* ci: remove --no-install-project when using optional extras

When --no-install-project is combined with --extra, the optional deps
are not installed because extras require the project to be active.
Remove --no-install-project from steps that need local-ml or embedded-db.

* ci: fix ordering of uv sync steps to preserve optional extras

When uv sync runs for a different workspace member, it removes optional
extras installed for other members. Fix by always running extra-requiring
API sync last, after other workspace member syncs.

Also remove --no-install-project from embedded-db sync in test-embed,
as --no-install-project prevents optional extras from being active.

* ci: add local-ml extra to test-embed API install for smoke test

The smoke test starts the full API server which needs sentence-transformers
for local embeddings (default provider). Add local-ml extra to the install.

* ci: simplify extras with --all-extras and add slim pip smoke test

- Replace explicit --extra local-ml --extra embedded-db with --all-extras
  for cleaner, more maintainable sync steps
- Add test-pip-slim job: tests hindsight-api-slim[embedded-db] without
  local ML models, using Cohere for embeddings/reranking (mirrors Docker
  slim smoke test approach)

* ci: simplify slim smoke test to health check only (mirrors Docker test)
2026-03-13 13:50:03 +01:00

138 lines
4.6 KiB
Python

"""
Safety tests to ensure all SQL queries use fully-qualified table names.
This prevents cross-tenant data access by ensuring every table reference
includes the schema prefix (e.g., public.memory_units instead of just memory_units).
"""
import re
from pathlib import Path
import pytest
# All tables that MUST be schema-qualified in SQL queries
TABLES = [
"memory_units",
"memory_links",
"unit_entities",
"entities",
"entity_cooccurrences",
"banks",
"documents",
"chunks",
"async_operations",
"directives",
"mental_models",
]
# Files to scan for SQL queries
SCAN_PATHS = [
"hindsight_api/engine",
"hindsight_api/api",
]
# Files to exclude (e.g., migrations, tests)
EXCLUDE_PATTERNS = [
"alembic",
"__pycache__",
"test_",
]
def get_python_files() -> list[Path]:
"""Get all Python files to scan."""
root = Path(__file__).parent.parent
files = []
for scan_path in SCAN_PATHS:
path = root / scan_path
if path.exists():
for py_file in path.rglob("*.py"):
# Check exclusions
if any(excl in str(py_file) for excl in EXCLUDE_PATTERNS):
continue
files.append(py_file)
return files
def find_unqualified_table_refs(content: str, filename: str) -> list[tuple[int, str, str]]:
"""
Find SQL statements with unqualified table references.
Returns list of (line_number, table_name, line_content).
"""
violations = []
# Patterns that indicate SQL context
sql_keywords = r"(?:FROM|JOIN|INTO|UPDATE|DELETE\s+FROM)\s+"
# Additional SQL indicators to confirm this is actually SQL, not prose
sql_indicators = re.compile(
r"(SELECT|INSERT|DELETE|UPDATE|CREATE|ALTER|DROP|WHERE|SET|VALUES|"
r'f"""|f\'\'\'|""".*SELECT|\'\'\'.*SELECT)',
re.IGNORECASE,
)
lines = content.split("\n")
for line_num, line in enumerate(lines, 1):
# Skip comments and strings that are clearly not SQL
stripped = line.strip()
if stripped.startswith("#"):
continue
for table in TABLES:
# Pattern: SQL keyword followed by unqualified table name
# Should match: FROM memory_units, JOIN memory_units, INTO memory_units
# Should NOT match: FROM public.memory_units, FROM {schema}.memory_units
# Should NOT match: fq_table("memory_units")
# Check for unqualified table after SQL keyword
pattern = rf"{sql_keywords}{table}(?:\s|$|,|\))"
if re.search(pattern, line, re.IGNORECASE):
# Check if it's actually qualified (has schema prefix)
qualified_pattern = rf"\.\s*{table}(?:\s|$|,|\))"
fq_table_pattern = rf'fq_table\s*\(\s*["\']?{table}'
if not re.search(qualified_pattern, line) and not re.search(
fq_table_pattern, line
):
# Additional check: line must have SQL indicators
# This avoids false positives in docstrings like "split into chunks"
if sql_indicators.search(line):
violations.append((line_num, table, stripped))
return violations
class TestSQLSchemaSafety:
"""Ensure all SQL uses schema-qualified table names."""
def test_no_unqualified_table_references(self):
"""All SQL queries must use fq_table() or schema.table format."""
all_violations = []
for py_file in get_python_files():
content = py_file.read_text()
violations = find_unqualified_table_refs(content, py_file.name)
for line_num, table, line in violations:
all_violations.append(
f"{py_file.relative_to(py_file.parent.parent)}:{line_num} - "
f"unqualified '{table}': {line[:80]}..."
)
if all_violations:
msg = (
f"Found {len(all_violations)} unqualified table references!\n"
"These could cause cross-tenant data access.\n"
"Use fq_table('table_name') for all table references.\n\n"
+ "\n".join(all_violations[:20]) # Show first 20
)
if len(all_violations) > 20:
msg += f"\n... and {len(all_violations) - 20} more"
pytest.fail(msg)
def test_tables_list_is_complete(self):
"""Verify we're checking for all tables (sanity check)."""
# This is a sanity check - if you add a new table, add it to TABLES
assert len(TABLES) >= 9, "Update TABLES list if you added new tables"