Ctrl K
ComunidadGuías…

tests/integration/files_intrusion_test.py

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
"""
tests/integration/files_intrusion.py

File ownership isolation sweep for Project David.
Tests that row-level access control is correctly enforced on all file endpoints.

Fixtures:
  owner    — OWNER_API_KEY
  intruder — INTRUDER_API_KEY

Run directly:
    python -m tests.integration.files_intrusion

Or via the compliance runner (exposes run_sweep()).
"""

import os
import tempfile
from typing import Optional

from dotenv import load_dotenv
from projectdavid import Entity

load_dotenv()

# ──────────────────────────────────────────────────────────────────────────────
# Config
# ──────────────────────────────────────────────────────────────────────────────
BASE_URL = os.getenv("ENTITIES_BASE_URL", "http://localhost:9000")
OWNER_KEY = os.getenv("OWNER_API_KEY")
INTRUDER_KEY = os.getenv("INTRUDER_API_KEY")

if not OWNER_KEY or not INTRUDER_KEY:
    raise RuntimeError(
        "Set OWNER_API_KEY and INTRUDER_API_KEY in your environment or .env file."
    )

# ──────────────────────────────────────────────────────────────────────────────
# Helpers
# ──────────────────────────────────────────────────────────────────────────────
results: dict = {}


def record(label: str, passed: bool, note: str = "") -> None:
    status = "PASS" if passed else "FAIL"
    tag = "✅" if passed else "❌"
    print(f"[{status}] {tag}  {label}" + (f": {note}" if note else ""))
    results[label] = status


def expect_success(label: str, fn, *args, **kwargs):
    try:
        result = fn(*args, **kwargs)
        record(label, True)
        return result
    except Exception as exc:
        record(label, False, str(exc))
        return None


def expect_error(label: str, expected_codes: tuple, fn, *args, **kwargs):
    try:
        fn(*args, **kwargs)
        record(label, False, "Expected error but call succeeded")
        return None
    except Exception as exc:
        msg = str(exc)
        matched = any(str(code) in msg for code in expected_codes)
        record(label, matched, msg)
        return exc


def expect_error_or_none(label: str, fn, *args, **kwargs):
    """
    Some SDK methods catch HTTP errors internally, log them, and return None
    rather than re-raising (e.g. get_file_as_base64, get_signed_url).

    This helper treats BOTH a raised exception AND a None return as a passing
    result — either means the caller was denied access.  A non-None return
    (actual content coming back) is the only real failure.
    """
    try:
        result = fn(*args, **kwargs)
        if result is None:
            # SDK swallowed the error and returned None — access was still denied
            record(
                label,
                True,
                "client returned None (error swallowed by SDK — access denied)",
            )
        else:
            record(
                label,
                False,
                f"Expected denial but received content: {str(result)[:80]}",
            )
        return result
    except Exception as exc:
        msg = str(exc)
        matched = any(str(code) in msg for code in (403, 404))
        record(label, matched, msg)
        return exc


def expect_404_any_form(label: str, fn, *args, **kwargs):
    """
    Some SDK methods translate HTTP 404 responses into a domain-level exception
    whose message does not contain the literal string '404' (e.g.
    "File with ID '...' not found.").

    This helper treats ANY exception raised after the call as a passing result —
    the intent is simply to confirm the resource is no longer accessible, not to
    validate a specific HTTP status code in the message text.
    """
    try:
        fn(*args, **kwargs)
        record(label, False, "Expected resource to be gone but call succeeded")
        return None
    except Exception as exc:
        record(label, True, str(exc))
        return exc


# ──────────────────────────────────────────────────────────────────────────────
# Clients  (module level — one instance per process)
# ──────────────────────────────────────────────────────────────────────────────
owner_client = Entity(base_url=BASE_URL, api_key=OWNER_KEY)
intruder_client = Entity(base_url=BASE_URL, api_key=INTRUDER_KEY)


# ──────────────────────────────────────────────────────────────────────────────
# Sweep
# ──────────────────────────────────────────────────────────────────────────────
def run_sweep() -> dict:
    results.clear()

    # Temp file created fresh each run — not at module level
    tmp = tempfile.NamedTemporaryFile(
        mode="w", suffix=".txt", prefix="files_isolation_", delete=False
    )
    tmp.write("Project David — file isolation test fixture\n")
    tmp.close()
    tmp_path = tmp.name
    print(f"\n[SETUP] Temp file created: {tmp_path}")

    owner_file_id: Optional[str] = None

    print("\n" + "═" * 60)
    print("FILE ISOLATION SWEEP")
    print("═" * 60)

    # ── Test 1: Owner uploads file ────────────────────────────────────────────
    print("\n--- Test 1: Owner uploads file (expecting success) ---")
    owner_file = expect_success(
        "Test 1: Owner uploads file",
        owner_client.files.upload_file,
        tmp_path,
        "assistants",
    )
    if owner_file:
        owner_file_id = owner_file.id
        print(f"  file.id      : {owner_file.id}")
        print(f"  file.filename: {owner_file.filename}")
        print(f"  file.purpose : {owner_file.purpose}")

    # ── Test 2: Owner retrieves own file metadata ─────────────────────────────
    print("\n--- Test 2: Owner retrieves own file metadata (expecting success) ---")
    if owner_file_id:
        retrieved = expect_success(
            "Test 2: Owner retrieves own file metadata",
            owner_client.files.retrieve_file,
            owner_file_id,
        )
        if retrieved:
            print(f"  retrieved.id       : {retrieved.id}")
            print(f"  retrieved.filename : {retrieved.filename}")

    # ── Test 3: Intruder retrieves owner's file metadata ─────────────────────
    print(
        "\n--- Test 3: Intruder retrieves owner's file metadata (expecting 403/404) ---"
    )
    if owner_file_id:
        expect_error(
            "Test 3: Intruder retrieves owner's file metadata",
            (403, 404),
            intruder_client.files.retrieve_file,
            owner_file_id,
        )

    # ── Test 4: Intruder requests Base64 of owner's file ─────────────────────
    # NOTE: the SDK's get_file_as_base64 catches HTTP errors internally and
    # returns None instead of re-raising.  We use expect_error_or_none so that
    # a None return (access denied, error swallowed) is treated as a pass.
    print(
        "\n--- Test 4: Intruder requests Base64 of owner's file (expecting 403/404) ---"
    )
    if owner_file_id:
        expect_error_or_none(
            "Test 4: Intruder requests Base64 of owner's file",
            intruder_client.files.get_file_as_base64,
            owner_file_id,
        )

    # ── Test 5: Owner gets Base64 of own file ─────────────────────────────────
    print("\n--- Test 5: Owner gets Base64 of own file (expecting success) ---")
    if owner_file_id:
        b64 = expect_success(
            "Test 5: Owner gets Base64 of own file",
            owner_client.files.get_file_as_base64,
            owner_file_id,
        )
        if b64:
            print(f"  base64 length: {len(b64)} chars")

    # ── Test 6: Intruder requests signed URL for owner's file ─────────────────
    # NOTE: same SDK swallowing issue as Test 4 — use expect_error_or_none.
    print(
        "\n--- Test 6: Intruder requests signed URL for owner's file (expecting 403/404) ---"
    )
    if owner_file_id:
        expect_error_or_none(
            "Test 6: Intruder requests signed URL for owner's file",
            intruder_client.files.get_signed_url,
            owner_file_id,
        )

    # ── Test 7: Owner gets signed URL for own file ────────────────────────────
    print("\n--- Test 7: Owner gets signed URL for own file (expecting success) ---")
    if owner_file_id:
        signed_url = expect_success(
            "Test 7: Owner gets signed URL for own file",
            owner_client.files.get_signed_url,
            owner_file_id,
        )
        if signed_url:
            print(f"  signed_url: {signed_url[:80]}...")

    # ── Test 8: Intruder deletes owner's file ─────────────────────────────────
    print("\n--- Test 8: Intruder deletes owner's file (expecting 403/404) ---")
    if owner_file_id:
        try:
            result = intruder_client.files.delete_file(owner_file_id)
            if result is True:
                record(
                    "Test 8: Intruder deletes owner's file",
                    False,
                    "Intruder call returned True — file may have been deleted!",
                )
            else:
                still_there = owner_client.files.retrieve_file(owner_file_id)
                record(
                    "Test 8: Intruder deletes owner's file",
                    bool(still_there),
                    (
                        "Returned False and file still exists for owner"
                        if still_there
                        else "File is gone after intruder call"
                    ),
                )
        except Exception as exc:
            msg = str(exc)
            matched = any(str(code) in msg for code in (403, 404))
            record("Test 8: Intruder deletes owner's file", matched, msg)

    # ── Test 9: Owner deletes own file ────────────────────────────────────────
    print("\n--- Test 9: Owner deletes own file (expecting success) ---")
    if owner_file_id:
        deleted = expect_success(
            "Test 9: Owner deletes own file",
            owner_client.files.delete_file,
            owner_file_id,
        )
        if deleted:
            print(f"  deleted: {deleted}")

    # ── Test 10: Deleted file returns 404 for owner ───────────────────────────
    # NOTE: the SDK translates the HTTP 404 into a domain exception whose message
    # does not contain the literal string "404" (e.g. "File with ID '...' not
    # found."), so the generic expect_error helper fails its code-matching check.
    # We use expect_404_any_form which treats any raised exception as a pass —
    # the goal is simply confirming the resource is gone, not parsing status codes.
    print("\n--- Test 10: Deleted file returns 404 for owner (expecting 404) ---")
    if owner_file_id:
        expect_404_any_form(
            "Test 10: Deleted file returns 404 for owner",
            owner_client.files.retrieve_file,
            owner_file_id,
        )

    # ── Teardown ──────────────────────────────────────────────────────────────
    print("\n" + "═" * 60)
    print("TEARDOWN")
    print("═" * 60)
    try:
        os.unlink(tmp_path)
        print(f"[OK] Temp file removed: {tmp_path}")
    except Exception:
        print(f"[WARN] Could not remove temp file: {tmp_path}")

    # ── Summary ───────────────────────────────────────────────────────────────
    print("\n" + "═" * 60)
    print("FILE ISOLATION SWEEP — SUMMARY")
    print("═" * 60)
    for label, outcome in results.items():
        tag = "✅" if outcome == "PASS" else "❌"
        print(f"  {tag}  {label}: {outcome}")
    print("═" * 60)

    return results


if __name__ == "__main__":
    run_sweep()