Files
app/backend/tests/test_cloud.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

409 lines
15 KiB
Python

"""Remote access: off by default, and only ever as much as somebody granted.
The property worth pinning down is the one everything else rests on — a
portal's token is worth nothing here until somebody at this instance
enrolled it, and even then it grants exactly the rights of the local account
the portal identity holding it was mapped to. An identity nobody mapped gets
nothing, which is what makes deleting that local account a revocation.
"""
from __future__ import annotations
import asyncio
import uuid
from dataclasses import replace
from unittest.mock import Mock, patch
import jwt
import pytest
from cryptography.hazmat.primitives.asymmetric import rsa
from fastapi.testclient import TestClient
from sqlmodel import Session
from fluksio.api.deps import decode_token, user_from_token
from fluksio.cloud import config as cloud_config
from fluksio.core.config import settings
from fluksio.flow.panels import PanelDef, PanelsConfig, write_config
from fluksio.models import User
from tests.utils.portal import ISSUER, portal_token
def test_portal_token_is_refused_when_not_enrolled(
portal_key: rsa.RSAPrivateKey, tmp_path_factory: pytest.TempPathFactory
) -> None:
"""An instance nobody connected trusts no portal at all."""
original = settings.CLOUD_CONFIG_FILE
settings.CLOUD_CONFIG_FILE = tmp_path_factory.mktemp("empty") / "cloud.json"
try:
with pytest.raises(jwt.exceptions.InvalidTokenError):
decode_token(portal_token(portal_key))
finally:
settings.CLOUD_CONFIG_FILE = original
def test_portal_token_resolves_by_portal_identity(
enrolled: User, portal_key: rsa.RSAPrivateKey, db: Session
) -> None:
"""The token names a person on the portal; the mapping names them here."""
claims = decode_token(portal_token(portal_key))
# No local account of its own: the payload carries who they are on the
# portal and nothing else, so an unmapped identity cannot fall back onto
# whoever enrolled.
assert "sub" not in claims
assert claims["portal_sub"] == "portal-user-1"
assert user_from_token(db, portal_token(portal_key)) == enrolled
assert user_from_token(db, portal_token(portal_key, subject="nobody")) is None
def test_token_for_another_instance_is_refused(
enrolled: User, # noqa: ARG001 (fixture installs the enrolment)
portal_key: rsa.RSAPrivateKey,
) -> None:
"""The audience is this instance's id, so someone else's is worthless."""
with pytest.raises(jwt.exceptions.InvalidTokenError):
decode_token(portal_token(portal_key, audience=str(uuid.uuid4())))
def test_token_from_an_unpinned_key_is_refused(
enrolled: User, # noqa: ARG001 (fixture installs the enrolment)
) -> None:
"""A different portal, or a hijacked one, cannot sign for this instance."""
impostor = rsa.generate_private_key(public_exponent=65537, key_size=2048)
with pytest.raises(jwt.exceptions.InvalidTokenError):
decode_token(portal_token(impostor))
def test_non_proxy_scope_is_refused(
enrolled: User, # noqa: ARG001 (fixture installs the enrolment)
portal_key: rsa.RSAPrivateKey,
) -> None:
with pytest.raises(jwt.exceptions.InvalidTokenError):
decode_token(portal_token(portal_key, scope="session"))
def test_disconnecting_ends_remote_access(
enrolled: User, portal_key: rsa.RSAPrivateKey
) -> None:
"""Deleting the config is the whole of the local revocation."""
assert decode_token(portal_token(portal_key))["portal_sub"] == enrolled.portal_sub
cloud_config.delete()
with pytest.raises(jwt.exceptions.InvalidTokenError):
decode_token(portal_token(portal_key))
def test_status_reports_not_enrolled(
client: TestClient, superuser_token_headers: dict[str, str]
) -> None:
response = client.get(
f"{settings.API_V1_STR}/cloud/status", headers=superuser_token_headers
)
assert response.status_code == 200
assert response.json()["enrolled"] is False
def test_enrolling_needs_a_superuser(
client: TestClient, normal_user_token_headers: dict[str, str]
) -> None:
"""Remote access is an instance-wide grant, not a personal setting."""
response = client.post(
f"{settings.API_V1_STR}/cloud/enroll",
headers=normal_user_token_headers,
json={"portal_url": ISSUER, "claim_code": "ABCD-EFGH"},
)
assert response.status_code == 403
def test_a_panel_scoped_portal_token_reaches_only_its_panel(
client: TestClient,
enrolled: User, # noqa: ARG001 (fixture installs the enrolment)
portal_key: rsa.RSAPrivateKey,
superuser_token_headers: dict[str, str],
) -> None:
"""A screen paired through the portal is bounded here, not there.
The portal names the panel; everything about what that means is this
instance's, which is the whole reason it may mint one at all.
"""
write_config(
PanelsConfig(
panels=[
PanelDef(id="hallway", dashboards=["kitchen"]),
PanelDef(id="workshop", dashboards=["bench"]),
]
)
)
for name in ("kitchen", "bench"):
created = client.post(
f"{settings.API_V1_STR}/dashboards/{name}", headers=superuser_token_headers
)
assert created.status_code in (200, 201, 409), created.text
# A new dashboard is a draft, and a panel only reaches what is
# published — so promote it before asking as one.
version = client.get(
f"{settings.API_V1_STR}/dashboards/{name}",
headers=superuser_token_headers,
params={"draft": "true"},
).json()["version"]
client.post(
f"{settings.API_V1_STR}/dashboards/{name}/publish",
headers=superuser_token_headers,
json={"version": version},
)
token = portal_token(portal_key, subject="hallway", scope="panel")
headers = {"Authorization": f"Bearer {token}"}
assert (
client.get(f"{settings.API_V1_STR}/panels/hallway", headers=headers).status_code
== 200
)
assert (
client.get(
f"{settings.API_V1_STR}/dashboards/kitchen", headers=headers
).status_code
== 200
)
# Another panel's dashboard, the panel list, and a draft are all refused.
assert (
client.get(
f"{settings.API_V1_STR}/dashboards/bench", headers=headers
).status_code
== 403
)
assert (
client.get(f"{settings.API_V1_STR}/panels/", headers=headers).status_code == 403
)
assert (
client.get(f"{settings.API_V1_STR}/flows/", headers=headers).status_code == 403
)
# Deleting the panel is how the screen is retired, whoever minted its token.
write_config(PanelsConfig(panels=[PanelDef(id="workshop", dashboards=["bench"])]))
assert (
client.get(f"{settings.API_V1_STR}/panels/hallway", headers=headers).status_code
== 401
)
def test_a_panel_token_naming_no_panel_is_refused(
enrolled: User, # noqa: ARG001 (fixture installs the enrolment)
portal_key: rsa.RSAPrivateKey,
) -> None:
"""It would otherwise fall through to the account it borrows."""
with pytest.raises(jwt.exceptions.InvalidTokenError):
decode_token(portal_token(portal_key, subject="", scope="panel"))
def test_adding_a_remote_user_maps_and_revokes(
client: TestClient,
enrolled: User, # noqa: ARG001 (fixture installs the enrolment)
portal_key: rsa.RSAPrivateKey,
superuser_token_headers: dict[str, str],
db: Session,
) -> None:
"""Admitting somebody makes an ordinary local user; deleting it ends them."""
portal_reply = Mock(
status_code=200,
json=Mock(
return_value={"user_id": "portal-user-9", "email": "remote@example.com"}
),
)
with patch("fluksio.cloud.enroll.httpx.post", return_value=portal_reply) as post:
added = client.post(
f"{settings.API_V1_STR}/cloud/users",
headers=superuser_token_headers,
json={"code": "ABCD-EFGH"},
)
assert added.status_code == 200, added.text
assert post.call_args.kwargs["headers"]["Authorization"].startswith("Bearer ")
body = added.json()
assert body["email"] == "remote@example.com"
assert body["portal_sub"] == "portal-user-9"
# Never a superuser: a remote user must not be able to admit anyone else.
assert body["is_superuser"] is False
theirs = portal_token(portal_key, subject="portal-user-9")
resolved = user_from_token(db, theirs)
assert resolved is not None and resolved.email == "remote@example.com"
with patch("fluksio.cloud.enroll.httpx.post", return_value=portal_reply):
again = client.post(
f"{settings.API_V1_STR}/cloud/users",
headers=superuser_token_headers,
json={"code": "ABCD-EFGH"},
)
assert again.status_code == 409
with patch(
"fluksio.api.routes.cloud.httpx.delete", return_value=Mock(status_code=200)
) as delete:
removed = client.delete(
f"{settings.API_V1_STR}/users/{body['id']}", headers=superuser_token_headers
)
assert removed.status_code == 200, removed.text
assert delete.call_args.args[0].endswith("/instance-members/portal-user-9")
db.expire_all()
assert user_from_token(db, theirs) is None
def test_adding_a_remote_user_needs_a_superuser(
client: TestClient,
enrolled: User, # noqa: ARG001 (fixture installs the enrolment)
normal_user_token_headers: dict[str, str],
) -> None:
"""Widening who can reach this instance stays a superuser's decision."""
response = client.post(
f"{settings.API_V1_STR}/cloud/users",
headers=normal_user_token_headers,
json={"code": "ABCD-EFGH"},
)
assert response.status_code == 403
def test_enrolling_against_a_portal_without_an_owner_is_refused(
client: TestClient,
superuser_token_headers: dict[str, str],
tmp_path_factory: pytest.TempPathFactory,
) -> None:
"""A portal too old to name the owner would leave nobody mapped here."""
original = settings.CLOUD_CONFIG_FILE
settings.CLOUD_CONFIG_FILE = tmp_path_factory.mktemp("old-portal") / "cloud.json"
reply = Mock(
status_code=200,
json=Mock(
return_value={
"instance_id": str(uuid.uuid4()),
"instance_token": "t",
"ws_url": f"{ISSUER}/api/v1/tunnel/attach",
"issuer": ISSUER,
"jwks": {"keys": []},
}
),
)
try:
with patch("fluksio.cloud.enroll.httpx.post", return_value=reply):
response = client.post(
f"{settings.API_V1_STR}/cloud/enroll",
headers=superuser_token_headers,
json={"portal_url": ISSUER, "claim_code": "ABCD-EFGH"},
)
assert response.status_code == 502
# Nothing was written: an enrolment nobody can act as is not one to keep.
assert not settings.CLOUD_CONFIG_FILE.exists()
finally:
settings.CLOUD_CONFIG_FILE = original
def test_an_older_enrolment_adopts_the_owner_on_attach(
enrolled: User, db: Session, portal_key: rsa.RSAPrivateKey
) -> None:
"""An enrolment made before per-user mapping is fixed by reconnecting.
Without this its owner would be refused until somebody enrolled the machine
again, which for a machine reached only through the portal means standing in
front of it.
"""
from fluksio.cloud.connector import CloudConnector
enrolled.portal_sub = None
db.add(enrolled)
db.commit()
config = cloud_config.load()
assert config is not None
assert user_from_token(db, portal_token(portal_key)) is None
CloudConnector._adopt_owner(config, "portal-user-1")
db.expire_all()
assert user_from_token(db, portal_token(portal_key)) == enrolled
# Somebody else already holding that identity is left alone: enrolment was
# told who this is, and this is only ever a repair.
other = User(
email="other@example.com", hashed_password="x", portal_sub="portal-user-2"
)
db.add(other)
db.commit()
CloudConnector._adopt_owner(config, "portal-user-2")
db.expire_all()
assert db.get(User, enrolled.id).portal_sub == "portal-user-1"
db.delete(other)
db.commit()
def test_the_owner_is_adopted_when_the_enrolling_row_is_gone(
enrolled: User, db: Session, portal_key: rsa.RSAPrivateKey
) -> None:
"""A rebuilt database keeps the enrolment and loses the account it names.
Restoring a backup, or the move to a different database, gives the same
operator a new row — and then `local_user_id` resolves to nobody, so a
portal session lands on no local user and is refused. That is a lockout on
a machine whose only route in is the portal, which is exactly what adopting
the owner exists to prevent.
"""
from fluksio.cloud.connector import CloudConnector
enrolled.portal_sub = None
db.add(enrolled)
db.commit()
# The enrolment now names an account that is not here any more.
stale = cloud_config.load()
assert stale is not None
cloud_config.save(replace(stale, local_user_id=str(uuid.uuid4())))
assert user_from_token(db, portal_token(portal_key)) is None
CloudConnector._adopt_owner(cloud_config.load(), "portal-user-1")
db.expire_all()
assert user_from_token(db, portal_token(portal_key)) == enrolled
# And recorded, so a panel borrowing the same field is not refused either.
healed = cloud_config.load()
assert healed is not None
assert healed.local_user_id == str(enrolled.id)
# -----------------------------------------------------------------------------
# Enrolling an engine that is already running
#
# `fluksio enroll` writes the config against the database from another process
# entirely, with no idea whether an engine is up. Noticing that is what saves a
# restart.
# -----------------------------------------------------------------------------
@pytest.mark.anyio
async def test_a_config_that_appears_while_running_is_dialled(monkeypatch) -> None:
from fluksio.cloud import connector
app = Mock()
app.state = Mock(cloud_task=None, cloud_connector=None)
started: list[object] = []
monkeypatch.setattr(connector, "ENROL_POLL_S", 0.01)
monkeypatch.setattr(connector, "start", lambda one: started.append(one))
monkeypatch.setattr(cloud_config, "load", lambda: object())
watcher = asyncio.create_task(connector.watch_enrolment(app))
await asyncio.sleep(0.05)
watcher.cancel()
assert started
@pytest.mark.anyio
async def test_a_config_that_cannot_be_read_is_not_dialled(monkeypatch) -> None:
"""Otherwise the connector gives up at once and this restarts it forever."""
from fluksio.cloud import connector
app = Mock()
app.state = Mock(cloud_task=None, cloud_connector=None)
started: list[object] = []
monkeypatch.setattr(connector, "ENROL_POLL_S", 0.01)
monkeypatch.setattr(connector, "start", lambda one: started.append(one))
# The file is there; it just does not parse, which `load` reports as None.
monkeypatch.setattr(cloud_config, "load", lambda: None)
watcher = asyncio.create_task(connector.watch_enrolment(app))
await asyncio.sleep(0.05)
watcher.cancel()
assert not started