2026-08-23 13:01:46 +02:00
|
|
|
"""Authentication boundary tests for the ops_run worker API."""
|
|
|
|
|
|
|
|
|
|
from __future__ import annotations
|
|
|
|
|
|
|
|
|
|
from unittest.mock import MagicMock
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
from fastapi import FastAPI, HTTPException
|
|
|
|
|
from fastapi.testclient import TestClient
|
|
|
|
|
|
2026-09-04 12:57:12 +02:00
|
|
|
from activity_core.ops_run_queue import CloseOpsRunOutcome
|
2026-08-23 13:01:46 +02:00
|
|
|
from activity_core.ops_runs_api import (
|
2026-09-04 12:57:12 +02:00
|
|
|
_close_response,
|
2026-08-23 13:01:46 +02:00
|
|
|
bind_worker_id,
|
|
|
|
|
require_worker,
|
|
|
|
|
require_worker_or_operator,
|
|
|
|
|
router,
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _request(headers: dict[str, str] | None = None) -> MagicMock:
|
|
|
|
|
request = MagicMock()
|
|
|
|
|
request.headers = headers or {}
|
|
|
|
|
return request
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _client() -> TestClient:
|
|
|
|
|
app = FastAPI()
|
|
|
|
|
app.include_router(router)
|
|
|
|
|
return TestClient(app)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_worker_token_binds_configured_identity(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
monkeypatch.setenv("ACTIVITY_CORE_WORKER_TOKEN", "worker-secret")
|
|
|
|
|
monkeypatch.setenv("ACTIVITY_CORE_WORKER_ID", "rein-aharness@railiance01")
|
|
|
|
|
|
|
|
|
|
authenticated = require_worker(
|
|
|
|
|
_request(),
|
|
|
|
|
x_worker_token="worker-secret",
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert authenticated == "rein-aharness@railiance01"
|
|
|
|
|
assert bind_worker_id("rein-aharness@railiance01", authenticated) == authenticated
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_spoofed_worker_id_is_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
monkeypatch.setenv("ACTIVITY_CORE_WORKER_TOKEN", "worker-secret")
|
|
|
|
|
monkeypatch.setenv("ACTIVITY_CORE_WORKER_ID", "rein-aharness@railiance01")
|
|
|
|
|
authenticated = require_worker(_request(), x_worker_token="worker-secret")
|
|
|
|
|
|
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
|
|
|
bind_worker_id("another-worker", authenticated)
|
|
|
|
|
|
|
|
|
|
assert exc.value.status_code == 403
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_worker_token_without_identity_fails_closed(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
monkeypatch.setenv("ACTIVITY_CORE_WORKER_TOKEN", "worker-secret")
|
|
|
|
|
monkeypatch.delenv("ACTIVITY_CORE_WORKER_ID", raising=False)
|
|
|
|
|
|
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
|
|
|
require_worker(_request(), x_worker_token="worker-secret")
|
|
|
|
|
|
|
|
|
|
assert exc.value.status_code == 503
|
|
|
|
|
assert "ACTIVITY_CORE_WORKER_ID" in exc.value.detail
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_invalid_worker_token_is_rejected(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
monkeypatch.setenv("ACTIVITY_CORE_WORKER_TOKEN", "worker-secret")
|
|
|
|
|
monkeypatch.setenv("ACTIVITY_CORE_WORKER_ID", "rein-aharness@railiance01")
|
|
|
|
|
|
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
|
|
|
require_worker(_request(), x_worker_token="wrong")
|
|
|
|
|
|
|
|
|
|
assert exc.value.status_code == 401
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_operator_or_sso_is_not_worker_identity(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
monkeypatch.delenv("ACTIVITY_CORE_WORKER_TOKEN", raising=False)
|
|
|
|
|
monkeypatch.delenv("ACTIVITY_CORE_WORKER_ID", raising=False)
|
|
|
|
|
monkeypatch.setenv("ACTIVITY_CORE_OPERATOR_TOKEN", "operator-secret")
|
|
|
|
|
|
|
|
|
|
for request, kwargs in (
|
|
|
|
|
(_request({"Remote-User": "alice"}), {}),
|
|
|
|
|
(_request(), {"authorization": "Bearer operator-secret"}),
|
|
|
|
|
):
|
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
|
|
|
require_worker(request, **kwargs)
|
|
|
|
|
assert exc.value.status_code == 503
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_claim_endpoint_rejects_operator_and_spoofed_identity(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
monkeypatch.setenv("ACTIVITY_CORE_WORKER_TOKEN", "worker-secret")
|
|
|
|
|
monkeypatch.setenv("ACTIVITY_CORE_WORKER_ID", "rein-aharness@railiance01")
|
|
|
|
|
monkeypatch.setenv("ACTIVITY_CORE_OPERATOR_TOKEN", "operator-secret")
|
|
|
|
|
client = _client()
|
|
|
|
|
body = {"worker_id": "rein-aharness@railiance01", "limit": 1}
|
|
|
|
|
|
|
|
|
|
operator = client.post(
|
|
|
|
|
"/ops-runs/claim",
|
|
|
|
|
json=body,
|
|
|
|
|
headers={"X-Operator-Token": "operator-secret"},
|
|
|
|
|
)
|
|
|
|
|
spoofed = client.post(
|
|
|
|
|
"/ops-runs/claim",
|
|
|
|
|
json={"worker_id": "another-worker", "limit": 1},
|
|
|
|
|
headers={"X-Worker-Token": "worker-secret"},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert operator.status_code == 401
|
|
|
|
|
assert spoofed.status_code == 403
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_unauthenticated_dev_worker_requires_explicit_opt_in(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
monkeypatch.delenv("ACTIVITY_CORE_WORKER_TOKEN", raising=False)
|
|
|
|
|
monkeypatch.delenv("ACTIVITY_CORE_WORKER_ID", raising=False)
|
|
|
|
|
monkeypatch.delenv("ACTIVITY_CORE_OPERATOR_TOKEN", raising=False)
|
|
|
|
|
monkeypatch.delenv("ACTIVITY_CORE_OPS_ALLOW_UNAUTH_MUTATIONS", raising=False)
|
|
|
|
|
|
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
|
|
|
require_worker(_request())
|
|
|
|
|
assert exc.value.status_code == 503
|
|
|
|
|
|
|
|
|
|
monkeypatch.setenv("ACTIVITY_CORE_OPS_ALLOW_UNAUTH_MUTATIONS", "true")
|
|
|
|
|
assert require_worker(_request()) == "dev:unauth"
|
|
|
|
|
assert bind_worker_id("local-worker", "dev:unauth") == "local-worker"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_read_auth_no_longer_defaults_open(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
monkeypatch.delenv("ACTIVITY_CORE_WORKER_TOKEN", raising=False)
|
|
|
|
|
monkeypatch.delenv("ACTIVITY_CORE_WORKER_ID", raising=False)
|
|
|
|
|
monkeypatch.delenv("ACTIVITY_CORE_OPERATOR_TOKEN", raising=False)
|
|
|
|
|
monkeypatch.delenv("ACTIVITY_CORE_OPS_ALLOW_UNAUTH_MUTATIONS", raising=False)
|
|
|
|
|
|
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
|
|
|
require_worker_or_operator(_request())
|
|
|
|
|
|
|
|
|
|
assert exc.value.status_code == 503
|
2026-09-04 12:57:12 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_close_response_distinguishes_reconciled_and_refusal_codes(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
row = MagicMock()
|
|
|
|
|
monkeypatch.setattr(
|
|
|
|
|
"activity_core.ops_runs_api.ops_run_to_dict",
|
|
|
|
|
lambda value: {"id": "run-1", "state": value.state},
|
|
|
|
|
)
|
|
|
|
|
row.state = "succeeded"
|
|
|
|
|
|
|
|
|
|
response = _close_response(CloseOpsRunOutcome("reconciled", row))
|
|
|
|
|
assert response == {
|
|
|
|
|
"id": "run-1",
|
|
|
|
|
"state": "succeeded",
|
|
|
|
|
"close_disposition": "reconciled",
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
|
|
|
_close_response(CloseOpsRunOutcome("expired_lease", row))
|
|
|
|
|
assert exc.value.status_code == 409
|
|
|
|
|
assert exc.value.detail["code"] == "expired_lease"
|
2026-09-23 17:38:41 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def _clear_worker_env(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
for name in (
|
|
|
|
|
"ACTIVITY_CORE_WORKERS",
|
|
|
|
|
"ACTIVITY_CORE_WORKER_ID",
|
|
|
|
|
"ACTIVITY_CORE_WORKER_TOKEN",
|
|
|
|
|
"ACTIVITY_CORE_WORKER_TOKEN_METERED",
|
|
|
|
|
"ACTIVITY_CORE_OPERATOR_TOKEN",
|
|
|
|
|
"ACTIVITY_CORE_OPS_ALLOW_UNAUTH_MUTATIONS",
|
|
|
|
|
):
|
|
|
|
|
monkeypatch.delenv(name, raising=False)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def _two_workers(monkeypatch: pytest.MonkeyPatch) -> None:
|
|
|
|
|
_clear_worker_env(monkeypatch)
|
|
|
|
|
monkeypatch.setenv(
|
|
|
|
|
"ACTIVITY_CORE_WORKERS",
|
|
|
|
|
"rein-aharness@railiance01=ACTIVITY_CORE_WORKER_TOKEN,"
|
|
|
|
|
"rein-aharness-metered@railiance01=ACTIVITY_CORE_WORKER_TOKEN_METERED",
|
|
|
|
|
)
|
|
|
|
|
monkeypatch.setenv("ACTIVITY_CORE_WORKER_TOKEN", "loop-secret")
|
|
|
|
|
monkeypatch.setenv("ACTIVITY_CORE_WORKER_TOKEN_METERED", "metered-secret")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_each_worker_token_binds_exactly_its_own_identity(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
_two_workers(monkeypatch)
|
|
|
|
|
|
|
|
|
|
loop = require_worker(_request(), x_worker_token="loop-secret")
|
|
|
|
|
metered = require_worker(_request(), authorization="Bearer metered-secret")
|
|
|
|
|
|
|
|
|
|
assert loop == "rein-aharness@railiance01"
|
|
|
|
|
assert metered == "rein-aharness-metered@railiance01"
|
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
|
|
|
bind_worker_id("rein-aharness@railiance01", metered)
|
|
|
|
|
assert exc.value.status_code == 403
|
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
|
|
|
require_worker(_request(), x_worker_token="unknown")
|
|
|
|
|
assert exc.value.status_code == 401
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_claim_endpoint_rejects_cross_identity_body(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
_two_workers(monkeypatch)
|
|
|
|
|
|
|
|
|
|
response = _client().post(
|
|
|
|
|
"/ops-runs/claim",
|
|
|
|
|
json={"worker_id": "rein-aharness@railiance01", "limit": 1},
|
|
|
|
|
headers={"X-Worker-Token": "metered-secret"},
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert response.status_code == 403
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
@pytest.mark.parametrize(
|
|
|
|
|
("workers", "env", "fragment"),
|
|
|
|
|
[
|
|
|
|
|
("a=ACTIVITY_CORE_WORKER_TOKEN,b=ACTIVITY_CORE_WORKER_TOKEN_METERED",
|
|
|
|
|
{"ACTIVITY_CORE_WORKER_TOKEN": "same", "ACTIVITY_CORE_WORKER_TOKEN_METERED": "same"},
|
|
|
|
|
"shared"),
|
|
|
|
|
("a=ACTIVITY_CORE_WORKER_TOKEN,a=ACTIVITY_CORE_WORKER_TOKEN_METERED",
|
|
|
|
|
{"ACTIVITY_CORE_WORKER_TOKEN": "x", "ACTIVITY_CORE_WORKER_TOKEN_METERED": "y"},
|
|
|
|
|
"duplicate"),
|
|
|
|
|
("a=ACTIVITY_CORE_WORKER_TOKEN,b=ACTIVITY_CORE_WORKER_TOKEN_METERED",
|
|
|
|
|
{"ACTIVITY_CORE_WORKER_TOKEN": "x"},
|
|
|
|
|
"ACTIVITY_CORE_WORKER_TOKEN_METERED"),
|
|
|
|
|
("a=ACTIVITY_CORE_OPERATOR_TOKEN",
|
|
|
|
|
{"ACTIVITY_CORE_OPERATOR_TOKEN": "op"},
|
|
|
|
|
"invalid"),
|
|
|
|
|
("a=ACTIVITY_CORE_WORKER_TOKEN",
|
|
|
|
|
{"ACTIVITY_CORE_WORKER_TOKEN": "op", "ACTIVITY_CORE_OPERATOR_TOKEN": "op"},
|
|
|
|
|
"operator"),
|
|
|
|
|
("a=ACTIVITY_CORE_WORKER_TOKEN",
|
|
|
|
|
{"ACTIVITY_CORE_WORKER_TOKEN": "x", "ACTIVITY_CORE_WORKER_ID": "b"},
|
|
|
|
|
"not listed"),
|
|
|
|
|
],
|
|
|
|
|
)
|
|
|
|
|
def test_ambiguous_worker_config_fails_closed(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
workers: str,
|
|
|
|
|
env: dict[str, str],
|
|
|
|
|
fragment: str,
|
|
|
|
|
) -> None:
|
|
|
|
|
_clear_worker_env(monkeypatch)
|
|
|
|
|
monkeypatch.setenv("ACTIVITY_CORE_WORKERS", workers)
|
|
|
|
|
for name, value in env.items():
|
|
|
|
|
monkeypatch.setenv(name, value)
|
|
|
|
|
|
|
|
|
|
with pytest.raises(HTTPException) as exc:
|
|
|
|
|
require_worker(_request(), x_worker_token="x")
|
|
|
|
|
|
|
|
|
|
assert exc.value.status_code == 503
|
|
|
|
|
assert fragment in exc.value.detail
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_broken_worker_config_does_not_lock_out_operator(
|
|
|
|
|
monkeypatch: pytest.MonkeyPatch,
|
|
|
|
|
) -> None:
|
|
|
|
|
_clear_worker_env(monkeypatch)
|
|
|
|
|
monkeypatch.setenv("ACTIVITY_CORE_WORKERS", "a=ACTIVITY_CORE_WORKER_TOKEN_MISSING")
|
|
|
|
|
monkeypatch.setenv("ACTIVITY_CORE_OPERATOR_TOKEN", "operator-secret")
|
|
|
|
|
|
|
|
|
|
principal = require_worker_or_operator(
|
|
|
|
|
_request(), authorization="Bearer operator-secret"
|
|
|
|
|
)
|
|
|
|
|
|
|
|
|
|
assert principal == "operator:token"
|