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
164 lines
5.4 KiB
Python
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
|