* fix(security): require keys for remote admin actions * fix(frontend): guard unavailable scrollIntoView * docs: link changelog to PR 1525 * fix(security): align PIN-only discovery policy * fix(security): preserve strict sidecar boundary * fix(security): normalize remote API keys * fix(auth): normalize credential fallback order
304 lines
12 KiB
Python
304 lines
12 KiB
Python
"""Joining a control plane from the app, instead of from the environment.
|
|
|
|
Before these endpoints, becoming a worker meant launching the app with
|
|
OMNIVOICE_WORKER_MODE and OMNIVOICE_WORKER_TOKEN set and relaunching — on the
|
|
machine that is usually the least convenient one to configure by hand. The
|
|
control plane could mint join codes that had nowhere to go.
|
|
|
|
What is pinned here is what makes the flow survive contact with reality:
|
|
|
|
* worker mode persists, so a machine that joined is still a worker after a
|
|
restart — but only after a join that actually worked, or a failed enrolment
|
|
would have the app retrying forever on every launch;
|
|
* the endpoint from the redeemed token is remembered, because the agent needs
|
|
it to reconnect and asking the user to also set OMNIVOICE_WORKER_ENDPOINT
|
|
would put the barrier straight back;
|
|
* a failed join answers with the reason ("that code expired"), not a bare 409,
|
|
because the user's next action depends on which failure it was;
|
|
* the environment still wins over the setting, so a deployment that pins
|
|
worker mode cannot be silently switched off from a UI.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import pytest
|
|
from fastapi import FastAPI
|
|
from fastapi.testclient import TestClient
|
|
|
|
from api.dependencies import require_admin
|
|
from api.routers import workers as workers_router
|
|
from worker import agent as worker_agent
|
|
|
|
|
|
@pytest.fixture
|
|
def client(monkeypatch, tmp_path):
|
|
"""The workers router with the admin guard stubbed out."""
|
|
settings: dict[str, str] = {}
|
|
|
|
class _Store:
|
|
@staticmethod
|
|
def get_text(key, default=""):
|
|
return settings.get(key, default)
|
|
|
|
@staticmethod
|
|
def set_text(key, value):
|
|
settings[key] = value
|
|
|
|
# Both bindings: `from services import settings_store` resolves the package
|
|
# ATTRIBUTE when another test has already imported the real module, and the
|
|
# sys.modules entry only when it has not — patching one leaves the outcome
|
|
# dependent on test order.
|
|
import services
|
|
|
|
monkeypatch.setattr(services, "settings_store", _Store, raising=False)
|
|
monkeypatch.setitem(__import__("sys").modules, "services.settings_store", _Store)
|
|
monkeypatch.delenv("OMNIVOICE_WORKER_MODE", raising=False)
|
|
monkeypatch.delenv("OMNIVOICE_WORKER_ENDPOINT", raising=False)
|
|
monkeypatch.setattr(
|
|
worker_agent, "_paths", lambda: {"pinned_cert": str(tmp_path / "pinned.crt")}
|
|
)
|
|
|
|
app = FastAPI()
|
|
app.include_router(workers_router.router)
|
|
app.dependency_overrides[require_admin] = lambda: None
|
|
with TestClient(app) as c:
|
|
yield c, settings
|
|
|
|
|
|
def _stub_agent(monkeypatch, *, fail: str = "", never_registers: str = ""):
|
|
"""Replace the real agent's start/stop/registration with recorded no-ops.
|
|
|
|
`fail` makes `start()` raise (a token that cannot even be redeemed);
|
|
`never_registers` makes the connection start fine and the control plane
|
|
never accept it — the case a scheduled-means-success join could not tell
|
|
apart from a working one.
|
|
"""
|
|
calls: list = []
|
|
|
|
async def _start(*, token_text: str = "", endpoint: str = ""):
|
|
calls.append(("start", token_text))
|
|
if fail:
|
|
raise RuntimeError(fail)
|
|
worker_agent.agent.endpoint = "studio-mac:7443"
|
|
|
|
async def _stop():
|
|
calls.append(("stop", ""))
|
|
|
|
async def _wait_until_registered(timeout: float = 20.0):
|
|
calls.append(("wait", ""))
|
|
if never_registers:
|
|
raise RuntimeError(never_registers)
|
|
|
|
monkeypatch.setattr(worker_agent.agent, "start", _start)
|
|
monkeypatch.setattr(worker_agent.agent, "stop", _stop)
|
|
monkeypatch.setattr(worker_agent.agent, "wait_until_registered", _wait_until_registered)
|
|
monkeypatch.setattr(worker_agent.agent, "last_error", "")
|
|
monkeypatch.setattr(worker_agent.agent, "endpoint", "")
|
|
return calls
|
|
|
|
|
|
def test_status_reports_a_machine_that_has_never_joined(client):
|
|
c, _ = client
|
|
body = c.get("/workers/agent").json()
|
|
assert body == {
|
|
"worker_mode": False,
|
|
"running": False,
|
|
"enrolled": False,
|
|
"endpoint": "",
|
|
"last_error": "",
|
|
"env_pinned": False,
|
|
}
|
|
|
|
|
|
def test_join_redeems_the_code_and_persists_worker_mode(client, monkeypatch):
|
|
c, settings = client
|
|
calls = _stub_agent(monkeypatch)
|
|
|
|
body = c.post("/workers/agent/join", json={"token": "ovw_abc123"}).json()
|
|
|
|
assert ("start", "ovw_abc123") in calls
|
|
# Persisted, so the machine is still a worker after a restart.
|
|
assert settings["worker_mode_enabled"] == "true"
|
|
assert body["worker_mode"] is True
|
|
assert body["endpoint"] == "studio-mac:7443"
|
|
|
|
|
|
def test_join_stops_any_previous_connection_first(client, monkeypatch):
|
|
c, _ = client
|
|
calls = _stub_agent(monkeypatch)
|
|
|
|
c.post("/workers/agent/join", json={"token": "ovw_abc123"})
|
|
|
|
# Re-joining a DIFFERENT control plane must not leave the old dial-out
|
|
# loop running against the machine the user just left.
|
|
assert calls[0][0] == "stop"
|
|
|
|
|
|
def test_a_failed_join_answers_with_the_reason_and_stays_off(client, monkeypatch):
|
|
c, settings = client
|
|
_stub_agent(monkeypatch, fail="This enrollment token has expired. Generate a new one.")
|
|
|
|
response = c.post("/workers/agent/join", json={"token": "ovw_expired"})
|
|
|
|
assert response.status_code == 409
|
|
assert "expired" in response.json()["detail"]
|
|
# Never persisted: a machine that failed to enrol must not come back up
|
|
# retrying forever.
|
|
assert "worker_mode_enabled" not in settings
|
|
assert c.get("/workers/agent").json()["last_error"].startswith("This enrollment token")
|
|
|
|
|
|
def test_a_join_the_control_plane_never_accepts_is_not_a_success(client, monkeypatch, tmp_path):
|
|
"""Scheduling the connection is not joining.
|
|
|
|
`start()` returns as soon as the dial-out loop is created, so a control
|
|
plane that rejects this worker — or never answers — used to persist worker
|
|
mode and report success, leaving the machine retrying forever against an
|
|
address that will not have it.
|
|
"""
|
|
c, settings = client
|
|
calls = _stub_agent(monkeypatch, never_registers="The control plane did not answer in time.")
|
|
|
|
response = c.post("/workers/agent/join", json={"token": "ovw_unreachable"})
|
|
|
|
assert response.status_code == 409
|
|
assert "did not answer" in response.json()["detail"]
|
|
assert "worker_mode_enabled" not in settings
|
|
# …and the half-started agent is not left dialling in the background.
|
|
assert calls[-1][0] == "stop"
|
|
|
|
|
|
def test_a_failed_rejoin_restores_the_working_enrollment(client, monkeypatch, tmp_path):
|
|
"""A rejoin that fails must not cost the user the control plane they had.
|
|
|
|
Pinning the new certificate overwrites the old one on disk, so without a
|
|
rollback a mistyped code left the machine unable to reconnect to anything.
|
|
"""
|
|
c, settings = client
|
|
calls = _stub_agent(monkeypatch, never_registers="That code has expired.")
|
|
pinned = tmp_path / "pinned.crt"
|
|
pinned.write_bytes(b"previous-control-plane")
|
|
settings["worker_mode_enabled"] = "true"
|
|
settings["worker_endpoint"] = "studio-mac:7443"
|
|
|
|
# The join overwrites the pinned certificate before it fails, exactly as
|
|
# pin_certificate does on the real path.
|
|
original_start = worker_agent.agent.start
|
|
|
|
async def _start_and_pin(*, token_text: str = "", endpoint: str = ""):
|
|
# Only a start that redeems a token re-pins, exactly like the real
|
|
# path — the rollback's own start() reuses what is on disk.
|
|
if token_text:
|
|
pinned.write_bytes(b"the-control-plane-that-rejected-us")
|
|
await original_start(token_text=token_text, endpoint=endpoint)
|
|
|
|
monkeypatch.setattr(worker_agent.agent, "start", _start_and_pin)
|
|
|
|
assert c.post("/workers/agent/join", json={"token": "ovw_expired"}).status_code == 409
|
|
# The certificate the machine still needs, put back after the failed pin.
|
|
assert pinned.read_bytes() == b"previous-control-plane"
|
|
# …and the agent it was running is dialling again. Without the rollback the
|
|
# machine sits stopped until someone notices and toggles it back on: the
|
|
# join stops the old agent before it knows the new code is any good.
|
|
assert calls[-1] == ("start", ""), (
|
|
f"expected the previous enrollment to be resumed, got {calls!r}"
|
|
)
|
|
assert settings["worker_endpoint"] == "studio-mac:7443"
|
|
assert settings["worker_mode_enabled"] == "true"
|
|
|
|
|
|
def test_an_env_pinned_machine_refuses_to_be_toggled(client, monkeypatch):
|
|
"""The environment wins everywhere else, so it wins here too.
|
|
|
|
Writing the setting under OMNIVOICE_WORKER_MODE would store a value the
|
|
rest of the app ignores, and the next restart would undo whatever the
|
|
toggle appeared to do.
|
|
"""
|
|
c, settings = client
|
|
_stub_agent(monkeypatch)
|
|
monkeypatch.setenv("OMNIVOICE_WORKER_MODE", "1")
|
|
|
|
response = c.post("/workers/agent/enabled", json={"enabled": False})
|
|
|
|
assert response.status_code == 409
|
|
assert "OMNIVOICE_WORKER_MODE" in response.json()["detail"]
|
|
assert "worker_mode_enabled" not in settings
|
|
|
|
|
|
def test_an_env_pinned_machine_refuses_a_join_too(client, monkeypatch):
|
|
"""Joining ENABLES worker mode, so the same rule applies as to the toggle.
|
|
|
|
Under OMNIVOICE_WORKER_MODE the join would persist a setting nothing
|
|
consults — and with the variable pinned off, hand the user a machine that
|
|
reports a successful join and never lends anything.
|
|
"""
|
|
c, settings = client
|
|
calls = _stub_agent(monkeypatch)
|
|
monkeypatch.setenv("OMNIVOICE_WORKER_MODE", "0")
|
|
|
|
response = c.post("/workers/agent/join", json={"token": "ovw_abc123"})
|
|
|
|
assert response.status_code == 409
|
|
assert "OMNIVOICE_WORKER_MODE" in response.json()["detail"]
|
|
assert calls == []
|
|
assert "worker_mode_enabled" not in settings
|
|
|
|
|
|
def test_join_rejects_an_empty_code(client, monkeypatch):
|
|
c, _ = client
|
|
calls = _stub_agent(monkeypatch)
|
|
assert c.post("/workers/agent/join", json={"token": " "}).status_code == 422
|
|
assert calls == []
|
|
|
|
|
|
def test_stopping_clears_the_setting_but_keeps_the_enrollment(client, monkeypatch, tmp_path):
|
|
c, settings = client
|
|
calls = _stub_agent(monkeypatch)
|
|
(tmp_path / "pinned.crt").write_bytes(b"cert")
|
|
|
|
c.post("/workers/agent/join", json={"token": "ovw_abc123"})
|
|
body = c.post("/workers/agent/enabled", json={"enabled": False}).json()
|
|
|
|
assert ("stop", "") in calls
|
|
assert settings["worker_mode_enabled"] == "false"
|
|
assert body["worker_mode"] is False
|
|
# The pinned certificate survives, which is what lets "on" resume without
|
|
# asking for another code.
|
|
assert body["enrolled"] is True
|
|
|
|
|
|
def test_resuming_needs_no_new_code(client, monkeypatch, tmp_path):
|
|
c, _ = client
|
|
calls = _stub_agent(monkeypatch)
|
|
(tmp_path / "pinned.crt").write_bytes(b"cert")
|
|
|
|
assert c.post("/workers/agent/enabled", json={"enabled": True}).status_code == 200
|
|
assert ("start", "") in calls
|
|
|
|
|
|
def test_the_environment_still_wins_over_the_stored_setting(client, monkeypatch):
|
|
c, settings = client
|
|
_stub_agent(monkeypatch)
|
|
c.post("/workers/agent/enabled", json={"enabled": False})
|
|
assert settings["worker_mode_enabled"] == "false"
|
|
|
|
monkeypatch.setenv("OMNIVOICE_WORKER_MODE", "1")
|
|
|
|
body = c.get("/workers/agent").json()
|
|
assert body["worker_mode"] is True
|
|
# …and the panel is told, so it disables a switch it cannot honour.
|
|
assert body["env_pinned"] is True
|
|
|
|
|
|
def test_the_redeemed_endpoint_is_remembered_for_the_next_launch(client, monkeypatch):
|
|
"""The token carries the address; forgetting it puts the barrier back.
|
|
|
|
A machine that joined from the UI used to come back up enrolled but with
|
|
nowhere to dial, and the only fix was OMNIVOICE_WORKER_ENDPOINT.
|
|
"""
|
|
c, settings = client
|
|
worker_agent._remember_endpoint("studio-mac:7443")
|
|
|
|
assert settings["worker_endpoint"] == "studio-mac:7443"
|
|
assert worker_agent._stored_endpoint() == "studio-mac:7443"
|
|
assert c.get("/workers/agent").json()["endpoint"] == "studio-mac:7443"
|