Files
app/backend/tests/conftest.py
T
stroblmeandClaude Opus 5 d01a8dad37 Rename Installation to Instance
Follows the portal: the noun is "instance" everywhere the app says it —
UI strings, CLI output, error details, docs and comments. The wire keys
(`instance_id`, `instance_token`) and the hub route this calls move with it.

An existing cloud.json is adopted rather than refused: without the key
alias the dataclass fails to parse, which the caller swallows and reads as
"never enrolled" instead of "reconnect".

`instance_key` on a node type becomes `target_key`. It means the outside
thing a node points at, which is a different sense of the word, and keeping
both would put two meanings of "instance" in one codebase.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015YrQnKV3bnQd4K342y8tKj
2026-08-31 10:12:01 +02:00

164 lines
5.4 KiB
Python

import uuid
from collections.abc import Generator
from datetime import UTC, datetime
from pathlib import Path
from typing import Any
import pytest
from cryptography.hazmat.primitives.asymmetric import rsa
from fastapi.testclient import TestClient
from sqlalchemy import delete, inspect
from sqlalchemy.engine import make_url
from sqlmodel import Session, select
from fluksio.cloud import config as cloud_config
from fluksio.core.config import settings
from fluksio.core.db import engine, prepare
from fluksio.main import app
from fluksio.models import (
EngineEvent,
FlowRun,
MetricBucket,
Run,
RunArtifact,
RunMetric,
RunNode,
User,
)
from tests.utils.portal import INSTANCE_ID, ISSUER, jwks
from tests.utils.user import authentication_token_from_email
from tests.utils.utils import get_superuser_token_headers
@pytest.fixture(scope="session", autouse=True)
def flow_data(tmp_path_factory: pytest.TempPathFactory) -> Generator[None, None, None]:
"""Keep flows and secrets written by tests out of the real store."""
root = tmp_path_factory.mktemp("flow-data")
settings.FLOWS_DIR = root / "flows"
settings.SECRETS_FILE = root / "secrets.enc"
settings.PANELS_FILE = root / "panels.json"
yield
@pytest.fixture(scope="session", autouse=True)
def db() -> Generator[Session, None, None]:
"""Create the throwaway database `tests/__init__.py` points at, drop it after."""
url = make_url(settings.SQLALCHEMY_DATABASE_URI)
# The teardown deletes this database, so refuse to run against anything but
# the dedicated test one.
assert url.get_backend_name() == "sqlite", url.get_backend_name()
assert url.database and url.database.endswith("_test.db"), url.database
path = Path(url.database)
path.unlink(missing_ok=True)
# The real path, so the suite runs against a database built the way a
# deployment's is — including the alembic stamp the app's own startup
# would otherwise trip over.
prepare(engine)
with Session(engine) as session:
yield session
engine.dispose()
# The write-ahead log and its index are part of the database.
for suffix in ("", "-wal", "-shm"):
path.with_name(path.name + suffix).unlink(missing_ok=True)
#: Tables the engine writes to on its own, from the collector and the run
#: service the module-scoped app starts.
_ENGINE_TABLES = (
RunArtifact,
RunMetric,
RunNode,
Run,
FlowRun,
EngineEvent,
MetricBucket,
)
@pytest.fixture(scope="module", autouse=True)
def _clean_engine_rows() -> Generator[None, None, None]:
"""Clear what a module wrote before the next one queries it.
The `db` session is session-scoped and every commit in it is permanent, so
a `FlowRun` one module left marked `running` turned up in another module's
query. Per-*test* rollback is not available here: the module-scoped
`client` runs the real lifespan, and the collector and run service write
through sessions of their own that no test transaction wraps. Clearing
between modules bounds it to where the writes actually come from.
"""
yield
# `tests/flow` overrides `db` with a no-op — the engine holds no database
# state, so those tests build no schema — and there is nothing to clear
# there. Running that directory on its own is how that shows.
if not inspect(engine).has_table("run"):
return
with Session(engine) as session:
for model in _ENGINE_TABLES:
session.execute(delete(model))
session.commit()
@pytest.fixture(scope="module")
def client() -> Generator[TestClient, None, None]:
with TestClient(app) as c:
yield c
@pytest.fixture(scope="module")
def superuser_token_headers(client: TestClient) -> dict[str, str]:
return get_superuser_token_headers(client)
@pytest.fixture(scope="module")
def normal_user_token_headers(client: TestClient, db: Session) -> dict[str, str]:
return authentication_token_from_email(
client=client, email=settings.EMAIL_TEST_USER, db=db
)
@pytest.fixture
def portal_key() -> rsa.RSAPrivateKey:
return rsa.generate_private_key(public_exponent=65537, key_size=2048)
@pytest.fixture
def enrolled(
tmp_path_factory: pytest.TempPathFactory,
portal_key: rsa.RSAPrivateKey,
db: Session,
) -> Any:
"""Enrol this instance with a fake portal, then undo it."""
local_user = db.exec(
select(User).where(User.email == settings.FIRST_SUPERUSER)
).one()
original = settings.CLOUD_CONFIG_FILE
settings.CLOUD_CONFIG_FILE = (
tmp_path_factory.mktemp(f"cloud-{uuid.uuid4().hex[:6]}") / "cloud.json"
)
cloud_config.save(
cloud_config.CloudConfig(
portal_url=ISSUER,
ws_url=f"{ISSUER}/api/v1/tunnel/attach",
instance_id=INSTANCE_ID,
token="instance-token",
issuer=ISSUER,
jwks=jwks(portal_key),
local_user_id=str(local_user.id),
enrolled_at=datetime.now(UTC).isoformat(),
portal_account=settings.FIRST_SUPERUSER,
)
)
# Enrolment also maps the enrolling account to the portal identity that
# owns the instance; without it a portal session resolves to nobody.
local_user.portal_sub = "portal-user-1"
db.add(local_user)
db.commit()
yield local_user
local_user.portal_sub = None
db.add(local_user)
db.commit()
cloud_config.delete()
settings.CLOUD_CONFIG_FILE = original