This commit is contained in:
@@ -0,0 +1,140 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import UTC, datetime
|
||||
|
||||
import pytest
|
||||
|
||||
from cloud.auth import Principal
|
||||
from cloud.config import CloudConfig
|
||||
from cloud.database import CloudDatabase
|
||||
from cloud.governance import enforce_user_submission_policy
|
||||
from cloud.plugins import PluginRegistry
|
||||
from cloud.pool import DevicePool
|
||||
from cloud.sdk.api import create_cloud_router
|
||||
from cloud.sdk.governance_api import create_governance_router
|
||||
from cloud.scheduler import TaskScheduler
|
||||
from cloud.user_auth import UserAccount
|
||||
from core.models import Device
|
||||
|
||||
|
||||
pytest.importorskip("fastapi")
|
||||
from fastapi import FastAPI # noqa: E402
|
||||
from fastapi.testclient import TestClient # noqa: E402
|
||||
|
||||
|
||||
class _PrincipalProvider:
|
||||
def __init__(self, principal: Principal) -> None:
|
||||
self.principal = principal
|
||||
|
||||
def authenticate(self, _request: object) -> Principal:
|
||||
return self.principal
|
||||
|
||||
|
||||
def _config() -> CloudConfig:
|
||||
return CloudConfig(max_queue_depth=20)
|
||||
|
||||
|
||||
def _add_user(repository, user_id: str = "user-a") -> None:
|
||||
now = datetime.now(UTC)
|
||||
repository.create_user(
|
||||
UserAccount(
|
||||
id=user_id,
|
||||
username=user_id,
|
||||
username_normalized=user_id,
|
||||
display_name=user_id,
|
||||
role="operator",
|
||||
enabled=True,
|
||||
must_change_password=False,
|
||||
authentication_version=1,
|
||||
created_at=now,
|
||||
updated_at=now,
|
||||
password_hash="not-used-by-this-test",
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def test_user_submission_policy_requires_permitted_explicit_target(tmp_path) -> None:
|
||||
database = CloudDatabase(f"sqlite:///{(tmp_path / 'governance.sqlite3').as_posix()}")
|
||||
try:
|
||||
_add_user(database.repository)
|
||||
database.repository.upsert_user_submission_policy(
|
||||
user_id="user-a",
|
||||
submission_enabled=True,
|
||||
allowed_host_ids=("host-a",),
|
||||
allowed_device_targets=(("host-a", "device-a"),),
|
||||
updated_at=datetime.now(UTC),
|
||||
)
|
||||
pool = DevicePool(database.repository, _config())
|
||||
pool.sync_host_devices(
|
||||
"host-a",
|
||||
[Device(id="device-a", driver_type="wda", status="idle")], # type: ignore[arg-type]
|
||||
)
|
||||
scheduler = TaskScheduler(pool, database.repository, _config())
|
||||
app = FastAPI()
|
||||
app.include_router(
|
||||
create_cloud_router(
|
||||
pool=pool,
|
||||
scheduler=scheduler,
|
||||
plugin_registry=PluginRegistry(database.repository),
|
||||
auth_provider=_PrincipalProvider(
|
||||
Principal(id="user:user-a", scopes=frozenset({"tasks:submit"}))
|
||||
),
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
denied = client.post("/v1/tasks", json={"goal": "unscoped"})
|
||||
assert denied.status_code == 403
|
||||
allowed = client.post(
|
||||
"/v1/tasks",
|
||||
json={
|
||||
"goal": "scoped",
|
||||
"constraints": {
|
||||
"target_host_id": "host-a",
|
||||
"target_device_id": "device-a",
|
||||
},
|
||||
},
|
||||
)
|
||||
assert allowed.status_code == 201, allowed.text
|
||||
finally:
|
||||
database.close()
|
||||
|
||||
|
||||
def test_governance_routes_persist_revisioned_policies(tmp_path) -> None:
|
||||
database = CloudDatabase(f"sqlite:///{(tmp_path / 'governance-api.sqlite3').as_posix()}")
|
||||
try:
|
||||
_add_user(database.repository)
|
||||
database.repository.upsert_host(
|
||||
"host-a", address=None, last_seen_at=datetime.now(UTC)
|
||||
)
|
||||
app = FastAPI()
|
||||
app.include_router(
|
||||
create_governance_router(
|
||||
repository=database.repository,
|
||||
auth_provider=_PrincipalProvider(
|
||||
Principal(id="admin", scopes=frozenset({"governance:admin", "governance:read"}))
|
||||
),
|
||||
)
|
||||
)
|
||||
client = TestClient(app)
|
||||
|
||||
user_policy = client.put(
|
||||
"/v1/users/user-a/submission-policy",
|
||||
json={"submission_enabled": False, "allowed_host_ids": []},
|
||||
)
|
||||
assert user_policy.status_code == 200, user_policy.text
|
||||
assert user_policy.json()["revision"] == 1
|
||||
host_policy = client.put(
|
||||
"/v1/hosts/host-a/governance-policy",
|
||||
json={"max_active_tasks": 2, "daily_token_budget": 1000},
|
||||
)
|
||||
assert host_policy.status_code == 200, host_policy.text
|
||||
assert host_policy.json()["revision"] == 1
|
||||
reread = client.get("/v1/hosts/host-a/governance-policy")
|
||||
assert reread.json()["daily_token_budget"] == 1000
|
||||
finally:
|
||||
database.close()
|
||||
|
||||
|
||||
def test_absent_policy_preserves_existing_submission_behavior() -> None:
|
||||
enforce_user_submission_policy(None, target_host_id=None, target_device_id=None)
|
||||
@@ -267,6 +267,70 @@ def test_submit_with_constraints(tmp_path) -> None:
|
||||
assert status["status"] == "queued"
|
||||
|
||||
|
||||
def test_submit_with_explicit_target_is_listed_and_not_rerouted(tmp_path) -> None:
|
||||
app, pool, scheduler, _ = _build_app(tmp_path)
|
||||
pool.sync_host_devices(
|
||||
"host-a",
|
||||
[Device(id="device-a", driver_type="wda", status="idle")], # type: ignore[arg-type]
|
||||
)
|
||||
pool.sync_host_devices(
|
||||
"host-b",
|
||||
[Device(id="device-b", driver_type="wda", status="idle")], # type: ignore[arg-type]
|
||||
)
|
||||
client = _client_for(app)
|
||||
|
||||
response = client.post(
|
||||
"/v1/tasks",
|
||||
json={
|
||||
"goal": "target b",
|
||||
"constraints": {
|
||||
"target_host_id": "host-b",
|
||||
"target_device_id": "device-b",
|
||||
},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
task_id = response.json()["task_id"]
|
||||
|
||||
scheduler.assign()
|
||||
task = client.get(f"/v1/tasks/{task_id}").json()
|
||||
assert task["target_host_id"] == "host-b"
|
||||
assert task["target_device_id"] == "device-b"
|
||||
assert task["assigned_host_id"] == "host-b"
|
||||
assert task["assigned_device_id"] == "device-b"
|
||||
listed = client.get("/v1/tasks").json()["items"]
|
||||
assert next(item for item in listed if item["id"] == task_id)["target_host_id"] == "host-b"
|
||||
|
||||
|
||||
def test_submit_rejects_incomplete_or_foreign_target(tmp_path) -> None:
|
||||
app, pool, _, _ = _build_app(tmp_path)
|
||||
pool.sync_host_devices(
|
||||
"host-a",
|
||||
[Device(id="device-a", driver_type="wda", status="idle")], # type: ignore[arg-type]
|
||||
)
|
||||
client = _client_for(app)
|
||||
|
||||
missing_host = client.post(
|
||||
"/v1/tasks",
|
||||
json={
|
||||
"goal": "invalid",
|
||||
"constraints": {"target_device_id": "device-a"},
|
||||
},
|
||||
)
|
||||
assert missing_host.status_code == 400
|
||||
foreign_device = client.post(
|
||||
"/v1/tasks",
|
||||
json={
|
||||
"goal": "invalid",
|
||||
"constraints": {
|
||||
"target_host_id": "host-a",
|
||||
"target_device_id": "unknown",
|
||||
},
|
||||
},
|
||||
)
|
||||
assert foreign_device.status_code == 400
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("method", "path", "payload", "required_scope"),
|
||||
[
|
||||
|
||||
@@ -10,7 +10,7 @@ from cloud.auth import BearerCredential, ConfiguredBearerAuthProvider
|
||||
from cloud.config import CloudConfig
|
||||
from cloud.internal_api.api import create_internal_router
|
||||
from cloud.pool import DevicePool, PooledDevice
|
||||
from cloud.scheduler import ScheduledTask, TaskConstraints
|
||||
from cloud.scheduler import ScheduledTask, TaskConstraints, TaskScheduler
|
||||
from cloud.store import CloudStore
|
||||
|
||||
|
||||
@@ -34,7 +34,13 @@ def _build_client(tmp_path) -> tuple[TestClient, DevicePool]:
|
||||
]
|
||||
)
|
||||
app = FastAPI()
|
||||
app.include_router(create_internal_router(pool=pool, auth_provider=auth_provider))
|
||||
app.include_router(
|
||||
create_internal_router(
|
||||
pool=pool,
|
||||
auth_provider=auth_provider,
|
||||
scheduler=TaskScheduler(pool, pool.store, CloudConfig(stale_after_seconds=60)),
|
||||
)
|
||||
)
|
||||
return TestClient(app), pool
|
||||
|
||||
|
||||
@@ -196,6 +202,36 @@ def test_host_token_cannot_submit_heartbeat_for_another_host(tmp_path) -> None:
|
||||
assert pool.store.get_host("host-b") is None
|
||||
|
||||
|
||||
def test_heartbeat_and_self_submission_preserve_host_isolation(tmp_path) -> None:
|
||||
client, pool = _build_client(tmp_path)
|
||||
headers = {"Authorization": "Bearer token-a"}
|
||||
heartbeat = client.put(
|
||||
"/internal/v1/hosts/host-a/heartbeat",
|
||||
headers=headers,
|
||||
json=_heartbeat_payload("host-a", "device-a"),
|
||||
)
|
||||
assert heartbeat.status_code == 200
|
||||
assert heartbeat.json()["policy_revision"] == 0
|
||||
assert heartbeat.json()["policy"] is None
|
||||
|
||||
created = client.post(
|
||||
"/internal/v1/hosts/host-a/tasks",
|
||||
headers=headers,
|
||||
json={"host_id": "host-a", "goal": "local work", "device_id": "device-a"},
|
||||
)
|
||||
assert created.status_code == 201, created.text
|
||||
task = pool.store.get_task(created.json()["task_id"])
|
||||
assert task is not None
|
||||
assert task.constraints.target_host_id == "host-a"
|
||||
assert task.constraints.target_device_id == "device-a"
|
||||
foreign = client.post(
|
||||
"/internal/v1/hosts/host-b/tasks",
|
||||
headers=headers,
|
||||
json={"host_id": "host-b", "goal": "forbidden"},
|
||||
)
|
||||
assert foreign.status_code == 403
|
||||
|
||||
|
||||
def test_long_poll_claim_returns_at_most_one_owned_assignment(tmp_path) -> None:
|
||||
client, pool = _build_client(tmp_path)
|
||||
now = datetime.now(UTC)
|
||||
|
||||
@@ -225,3 +225,42 @@ def test_capability_tag_constraint_filters_candidates(tmp_path) -> None:
|
||||
assignments = scheduler.assign()
|
||||
assert [a.task_id for a in assignments] == [task_id]
|
||||
assert assignments[0].device_id == "dev-2"
|
||||
|
||||
|
||||
def test_explicit_host_and_device_target_is_a_hard_constraint(tmp_path) -> None:
|
||||
pool = _pool_with_devices(tmp_path, _device("host-a-device"), host_id="host-a")
|
||||
pool.sync_host_devices("host-b", [_device("host-b-device")])
|
||||
scheduler = TaskScheduler(pool, pool.store, _config())
|
||||
|
||||
task_id = scheduler.submit(
|
||||
goal="target host b",
|
||||
constraints=TaskConstraints(
|
||||
target_host_id="host-b",
|
||||
target_device_id="host-b-device",
|
||||
),
|
||||
)
|
||||
|
||||
assignments = scheduler.assign()
|
||||
|
||||
assert [(item.host_id, item.device_id) for item in assignments] == [
|
||||
("host-b", "host-b-device")
|
||||
]
|
||||
task = pool.store.get_task(task_id)
|
||||
assert task is not None
|
||||
assert task.constraints.target_host_id == "host-b"
|
||||
assert task.constraints.target_device_id == "host-b-device"
|
||||
|
||||
|
||||
def test_unavailable_explicit_target_is_never_rerouted(tmp_path) -> None:
|
||||
pool = _pool_with_devices(tmp_path, _device("host-a-device"), host_id="host-a")
|
||||
pool.sync_host_devices("host-b", [_device("host-b-device", status="busy")])
|
||||
scheduler = TaskScheduler(pool, pool.store, _config())
|
||||
task_id = scheduler.submit(
|
||||
goal="wait for host b",
|
||||
constraints=TaskConstraints(target_host_id="host-b"),
|
||||
)
|
||||
|
||||
assert scheduler.assign() == []
|
||||
task = pool.store.get_task(task_id)
|
||||
assert task is not None
|
||||
assert task.status == "queued"
|
||||
|
||||
Reference in New Issue
Block a user