installer v1 (A+B): install-code enrollment, panel code UI, EXE publish; DPAPI credential store
This commit is contained in:
@@ -0,0 +1,419 @@
|
||||
"""Install-code enrollment flow: POST /v1/agents/enroll with install_code.
|
||||
|
||||
Covers the universal-EXE enrollment path — the operator types a 16-char
|
||||
XXXX-XXXX-XXXX-XXXX installation code into Setup.exe, the Hub resolves the
|
||||
installation by the hash on its own (no installation_id, no enrollment.json),
|
||||
issues the permanent agent credential, and exhausts the code (replay-proof).
|
||||
Also covers rejection when code and legacy token are sent together, expiry,
|
||||
attempts/rate-limit and no-plaintext persistencen.
|
||||
"""
|
||||
import dataclasses
|
||||
import json
|
||||
import sys
|
||||
import tempfile
|
||||
import zipfile
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import select
|
||||
|
||||
from central.app import config, enrollment, rate_limit, release_storage
|
||||
from central.app.main import create_app
|
||||
from central.app.models import Agent, AgentEnrollment, AgentRelease, AuditEvent, Installation, Tenant
|
||||
from central.scripts.publish_agent_release import publish
|
||||
from central.tests.test_control_plane import seed, setup_db
|
||||
|
||||
VERSION = "1.1.1"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def clear_rate_limit():
|
||||
rate_limit._attempts.clear()
|
||||
yield
|
||||
rate_limit._attempts.clear()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def hub(tmp_path, monkeypatch):
|
||||
replaced = dataclasses.replace(
|
||||
config.settings,
|
||||
agent_release_storage_path=str(tmp_path / "releases"),
|
||||
agent_enrollment_ttl_seconds=86400,
|
||||
base_url="https://gtifiscal.example.gti",
|
||||
web_cookie_secure=False,
|
||||
)
|
||||
monkeypatch.setattr(config, "settings", replaced)
|
||||
if "app.config" in sys.modules:
|
||||
monkeypatch.setattr(sys.modules["app.config"], "settings", replaced)
|
||||
factory = setup_db()
|
||||
seed(factory)
|
||||
client = TestClient(create_app(database_probe=lambda: True, session_factory=factory, bootstrap_token=None))
|
||||
return client, factory
|
||||
|
||||
|
||||
def login(client):
|
||||
response = client.post("/login", data={"username": "operator", "password": "a-secure-password"}, follow_redirects=False)
|
||||
assert response.status_code == 303
|
||||
|
||||
|
||||
def csrf(client):
|
||||
return client.cookies.get("gti_csrf", "")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def release_id(hub, tmp_path):
|
||||
_client, factory = hub
|
||||
data = io_bytes_tmp(tmp_path, VERSION)
|
||||
with factory() as session:
|
||||
release, _created = publish(session, data, version=VERSION)
|
||||
return release.id
|
||||
|
||||
|
||||
def io_bytes_tmp(tmp_path, version):
|
||||
from pathlib import Path
|
||||
buf = io_bytes_bundle(version)
|
||||
path = Path(tmp_path) / f"GTIFiscalAgent-{version}.zip"
|
||||
path.write_bytes(buf)
|
||||
return path
|
||||
|
||||
|
||||
def io_bytes_bundle(version):
|
||||
import io
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w") as z:
|
||||
z.writestr(f"GTIFiscalAgent-{version}/gti-agent.ps1", "# fake\n")
|
||||
z.writestr(f"GTIFiscalAgent-{version}/VERSION", version)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def tenant_id(factory):
|
||||
with factory() as session:
|
||||
return session.scalar(select(Tenant).where(Tenant.name == "Acme")).id
|
||||
|
||||
|
||||
def make_installation(factory, release_id, name="Servidor E2E"):
|
||||
with factory() as session:
|
||||
enr = AgentEnrollment(enrollment_uuid=str(__import__("uuid").uuid4()), tenant_id=tenant_id(factory),
|
||||
display_name=name, release_id=release_id, status="PENDING")
|
||||
session.add(enr)
|
||||
session.commit()
|
||||
return enr.enrollment_uuid
|
||||
|
||||
|
||||
def set_code(factory, uuid, code, ttl_seconds=86400):
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uuid))
|
||||
enr.install_code_hash = enrollment.install_code_digest(code)
|
||||
enr.install_code_expires_at = datetime.now(timezone.utc) + timedelta(seconds=ttl_seconds)
|
||||
enr.install_code_created_at = datetime.now(timezone.utc)
|
||||
session.commit()
|
||||
|
||||
|
||||
def audits(factory):
|
||||
with factory() as session:
|
||||
return session.scalars(select(AuditEvent)).all()
|
||||
|
||||
|
||||
# ---------- happy path: code alone resolves the installation ----------
|
||||
|
||||
def test_enroll_with_install_code_alone_creates_agent(hub, release_id):
|
||||
client, factory = hub
|
||||
uuid = make_installation(factory, release_id)
|
||||
code = enrollment.generate_install_code()
|
||||
set_code(factory, uuid, code)
|
||||
|
||||
enrolled = client.post("/v1/agents/enroll", json={"install_code": code, "agent_version": "1.1.1", "hostname": "WIN-E2E", "platform": "windows"})
|
||||
assert enrolled.status_code == 200
|
||||
body = enrolled.json()
|
||||
assert body["agent_id"] and body["agent_token"] and body["hostname_mismatch"] is False
|
||||
|
||||
with factory() as session:
|
||||
agent = session.get(Agent, body["agent_id"])
|
||||
assert agent.enabled and agent.agent_version == "1.1.1"
|
||||
installation = session.scalar(select(Installation).where(Installation.agent_id == agent.id))
|
||||
assert installation.installation_id == uuid and installation.hostname == "WIN-E2E"
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uuid))
|
||||
assert enr.status == "ENROLLED" and enr.agent_id == agent.id
|
||||
assert enr.install_code_hash is None and enr.install_code_expires_at is None # code consumed, replay-proof
|
||||
|
||||
|
||||
def test_replay_of_consumed_code_is_rejected(hub, release_id):
|
||||
client, factory = hub
|
||||
uuid = make_installation(factory, release_id)
|
||||
code = enrollment.generate_install_code()
|
||||
set_code(factory, uuid, code)
|
||||
first = client.post("/v1/agents/enroll", json={"install_code": code})
|
||||
assert first.status_code == 200
|
||||
second = client.post("/v1/agents/enroll", json={"install_code": code})
|
||||
assert second.status_code == 401
|
||||
|
||||
events = {e.action for e in audits(factory)}
|
||||
assert "ENROLLMENT_SUCCEEDED" in events and "ENROLLMENT_REJECTED" in events
|
||||
raw = json.dumps({"action": "ENROLLMENT_SUCCEEDED", "target": ["x"], "context": {"ip": "y"}}) + code
|
||||
# the code plaintext is never in any audit event context
|
||||
for event in audits(factory):
|
||||
assert code not in json.dumps(event.context)
|
||||
assert enrollment.normalize_install_code(code) not in json.dumps(event.context)
|
||||
|
||||
|
||||
def test_code_is_case_and_hyphen_insensitive(hub, release_id):
|
||||
client, factory = hub
|
||||
uuid = make_installation(factory, release_id)
|
||||
code = enrollment.generate_install_code()
|
||||
set_code(factory, uuid, code)
|
||||
typed = code.lower().replace("-", " ")
|
||||
enrolled = client.post("/v1/agents/enroll", json={"install_code": typed})
|
||||
assert enrolled.status_code == 200
|
||||
|
||||
|
||||
# ---------- rejections ----------
|
||||
|
||||
def test_invalid_code_format_rejected_without_lookup(hub, release_id):
|
||||
client, factory = hub
|
||||
invalid = client.post("/v1/agents/enroll", json={"install_code": "NOT-A-CODE"})
|
||||
assert invalid.status_code == 400 or invalid.status_code == 401
|
||||
assert "INVALID" in str(invalid.json())
|
||||
|
||||
|
||||
def test_unknown_but_wellformed_code_rejected(hub, release_id):
|
||||
client, factory = hub
|
||||
# valid-format code that was never issued for this Hub
|
||||
made_up = "-".join(["0123"] * 4)
|
||||
rejected = client.post("/v1/agents/enroll", json={"install_code": made_up})
|
||||
assert rejected.status_code == 401
|
||||
assert "INVALID" in str(rejected.json())
|
||||
|
||||
|
||||
def test_expired_code_rejected(hub, release_id):
|
||||
client, factory = hub
|
||||
uuid = make_installation(factory, release_id)
|
||||
code = enrollment.generate_install_code()
|
||||
set_code(factory, uuid, code, ttl_seconds=-60)
|
||||
rejected = client.post("/v1/agents/enroll", json={"install_code": code})
|
||||
assert rejected.status_code == 401
|
||||
assert "EXPIRED" in str(rejected.json())
|
||||
|
||||
|
||||
def test_code_for_revoked_installation_rejected(hub, release_id):
|
||||
client, factory = hub
|
||||
uuid = make_installation(factory, release_id)
|
||||
code = enrollment.generate_install_code()
|
||||
set_code(factory, uuid, code)
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uuid))
|
||||
enr.status = "REVOKED"
|
||||
session.commit()
|
||||
rejected = client.post("/v1/agents/enroll", json={"install_code": code})
|
||||
assert rejected.status_code == 401
|
||||
assert "REVOKED" in str(rejected.json())
|
||||
|
||||
|
||||
def test_code_and_token_together_rejected(hub, release_id):
|
||||
client, factory = hub
|
||||
uuid = make_installation(factory, release_id)
|
||||
code = enrollment.generate_install_code()
|
||||
set_code(factory, uuid, code)
|
||||
rejected = client.post("/v1/agents/enroll", json={
|
||||
"install_code": code, "installation_id": uuid, "enrollment_token": "x" * 43,
|
||||
})
|
||||
assert rejected.status_code == 400
|
||||
|
||||
|
||||
def test_failed_attempts_increment_and_rate_limit(hub, release_id):
|
||||
client, factory = hub
|
||||
uuid = make_installation(factory, release_id)
|
||||
code = enrollment.generate_install_code()
|
||||
set_code(factory, uuid, code)
|
||||
# several wrong-format tries that never reach the code lookup
|
||||
for _ in range(12):
|
||||
client.post("/v1/agents/enroll", json={"install_code": "NOT-A-CODE"})
|
||||
assert rate_limit.blocked("enroll", "testclient")
|
||||
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uuid))
|
||||
assert enr.install_code_attempts >= 0 # format failures don't consume this installation's code attempts
|
||||
|
||||
|
||||
def test_wrong_code_rejected_as_invalid(hub, release_id):
|
||||
client, factory = hub
|
||||
uuid = make_installation(factory, release_id)
|
||||
right_code = enrollment.generate_install_code()
|
||||
set_code(factory, uuid, right_code)
|
||||
wrong = enrollment.generate_install_code() # well-formed but never issued
|
||||
attempt = client.post("/v1/agents/enroll", json={"install_code": wrong})
|
||||
assert attempt.status_code == 401
|
||||
assert "INVALID" in str(attempt.json())
|
||||
|
||||
|
||||
def test_resolved_code_failures_increment_attempt_budget(hub, release_id):
|
||||
# A well-formed code that resolves to a REVOKED installation consumes the
|
||||
# installation's attempt budget (per-installation rate limit), same as an
|
||||
# expired one.
|
||||
client, factory = hub
|
||||
uuid = make_installation(factory, release_id)
|
||||
code = enrollment.generate_install_code()
|
||||
set_code(factory, uuid, code)
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uuid))
|
||||
enr.status = "REVOKED"
|
||||
session.commit()
|
||||
for _ in range(3):
|
||||
client.post("/v1/agents/enroll", json={"install_code": code})
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uuid))
|
||||
assert enr.install_code_attempts >= 3
|
||||
|
||||
|
||||
def test_legacy_token_path_still_works(hub, release_id):
|
||||
client, factory = hub
|
||||
uuid = make_installation(factory, release_id)
|
||||
token = enrollment.new_token()
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == uuid))
|
||||
enr.enrollment_token_hash = enrollment.token_digest(token)
|
||||
enr.enrollment_token_expires_at = datetime.now(timezone.utc) + timedelta(seconds=86400)
|
||||
session.commit()
|
||||
enrolled = client.post("/v1/agents/enroll", json={"installation_id": uuid, "enrollment_token": token})
|
||||
assert enrolled.status_code == 200
|
||||
|
||||
|
||||
# ---------- publish EXE releases (immutable) ----------
|
||||
|
||||
def test_publish_exe_release(hub, tmp_path):
|
||||
client, factory = hub
|
||||
from central.app import release_storage
|
||||
exe_bytes = b"MZ\x90\x00 fake windows installer exe body \x00" * 100
|
||||
exe_path = Path(tmp_path) / "GTIFiscalAgent-Setup-1.1.1.exe"
|
||||
exe_path.write_bytes(exe_bytes)
|
||||
with factory() as session:
|
||||
release, created = publish(session, exe_path, version="1.1.1", notes="universal installer")
|
||||
assert created and release.status == "PUBLISHED" and release.published_at is not None
|
||||
assert release.filename == "GTIFiscalAgent-Setup-1.1.1.exe"
|
||||
assert release_storage.safe_path_for(release.storage_path).read_bytes() == exe_bytes
|
||||
|
||||
|
||||
def test_publish_exe_is_immutable(hub, tmp_path):
|
||||
_client, factory = hub
|
||||
exe_path = Path(tmp_path) / "GTIFiscalAgent-Setup-1.1.1.exe"
|
||||
exe_path.write_bytes(b"MZ fake body v1")
|
||||
with factory() as session:
|
||||
publish(session, exe_path, version="1.1.1")
|
||||
exe_path.write_bytes(b"MZ DIFFERENT body v2")
|
||||
with factory() as session:
|
||||
with pytest.raises(SystemExit):
|
||||
publish(session, exe_path, version="1.1.1") # refused: immutable once PUBLISHED
|
||||
|
||||
|
||||
def test_publish_identical_exe_is_noop(hub, tmp_path):
|
||||
_client, factory = hub
|
||||
exe_path = Path(tmp_path) / "GTIFiscalAgent-Setup-1.1.1.exe"
|
||||
exe_path.write_bytes(b"MZ same bytes")
|
||||
with factory() as session:
|
||||
release, created = publish(session, exe_path, version="1.1.1")
|
||||
assert created
|
||||
with factory() as session:
|
||||
release2, created2 = publish(session, exe_path, version="1.1.1")
|
||||
assert release2.id == release.id and not created2
|
||||
|
||||
|
||||
# ---------- EXE download flow (panel: code -> download -> enroll) ----------
|
||||
|
||||
def publish_exe_release(factory, tmp_path):
|
||||
exe_path = tmp_path / "GTIFiscalAgent-Setup-1.1.1.exe"
|
||||
exe_path.write_bytes(b"MZ\x90\x00 GTIFiscalAgent installer 1.1.1 \x00" * 40)
|
||||
with factory() as session:
|
||||
release, _created = publish(session, exe_path, version="1.1.1")
|
||||
return release.id
|
||||
|
||||
|
||||
def test_panel_flow_code_generate_download_and_enroll_with_exe(hub, tmp_path):
|
||||
client, factory = hub
|
||||
release_id = publish_exe_release(factory, tmp_path)
|
||||
login(client)
|
||||
created = client.post("/agents/new", data={"csrf_token": csrf(client), "tenant_id": tenant_id(factory),
|
||||
"display_name": "Servidor EXE", "release_id": release_id}, follow_redirects=False)
|
||||
assert created.status_code == 303
|
||||
installation_uuid = created.headers["location"].rsplit("/", 1)[-1]
|
||||
|
||||
# generate the install code (only hash stored; plaintext shown once via ?code=)
|
||||
code_page = client.post(f"/agents/pending/{installation_uuid}/code", data={"csrf_token": csrf(client)}, follow_redirects=True)
|
||||
assert code_page.status_code == 200
|
||||
# the redirect carried ?code=XXXX-... ; the page must show it
|
||||
code_addr = code_page.history[0].headers.get("location") if code_page.history else ""
|
||||
assert "code=" in code_addr
|
||||
install_code = code_addr.rsplit("code=", 1)[-1].split("&")[0]
|
||||
assert len(install_code) == 19
|
||||
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == installation_uuid))
|
||||
assert enr.install_code_hash == enrollment.install_code_digest(install_code)
|
||||
assert enr.install_code_expires_at is not None
|
||||
assert enr.install_code_hash != install_code # never plaintext in DB
|
||||
|
||||
# download the EXE from the pending page (authenticated direct download)
|
||||
downloaded = client.get(f"/agents/pending/{installation_uuid}/download")
|
||||
assert downloaded.status_code == 200
|
||||
assert downloaded.content.startswith(b"MZ")
|
||||
assert "attachment" in downloaded.headers["content-disposition"]
|
||||
assert "GTIFiscalAgent-Setup-1.1.1.exe" in downloaded.headers["content-disposition"]
|
||||
|
||||
# enroll with just the code (no installation id, no token)
|
||||
anon = TestClient(client.app)
|
||||
enrolled = anon.post("/v1/agents/enroll", json={"install_code": install_code, "agent_version": "1.1.1", "hostname": "WIN-EXE"})
|
||||
assert enrolled.status_code == 200
|
||||
body = enrolled.json()
|
||||
assert body["agent_id"] and body["agent_token"]
|
||||
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == installation_uuid))
|
||||
assert enr.status == "ENROLLED" and enr.install_code_hash is None # consumed
|
||||
agent = session.get(Agent, body["agent_id"])
|
||||
assert agent.enabled and agent.agent_version == "1.1.1"
|
||||
|
||||
events = {e.action for e in audits(factory)}
|
||||
assert {"INSTALLATION_CREATED", "INSTALLATION_CODE_CREATED", "INSTALLER_DOWNLOADED", "ENROLLMENT_STARTED",
|
||||
"ENROLLMENT_SUCCEEDED", "AGENT_CREDENTIAL_ISSUED"} <= events
|
||||
for event in audits(factory):
|
||||
assert install_code not in json.dumps(event.context)
|
||||
|
||||
|
||||
def test_rotation_generates_a_code_that_invalidates_the_previous(hub, tmp_path):
|
||||
client, factory = hub
|
||||
release_id = publish_exe_release(factory, tmp_path)
|
||||
login(client)
|
||||
created = client.post("/agents/new", data={"csrf_token": csrf(client), "tenant_id": tenant_id(factory),
|
||||
"display_name": "Rotação", "release_id": release_id}, follow_redirects=False)
|
||||
installation_uuid = created.headers["location"].rsplit("/", 1)[-1]
|
||||
first = client.post(f"/agents/pending/{installation_uuid}/code", data={"csrf_token": csrf(client)}, follow_redirects=True)
|
||||
first_code = first.history[0].headers["location"].rsplit("code=", 1)[-1].split("&")[0]
|
||||
second = client.post(f"/agents/pending/{installation_uuid}/code", data={"csrf_token": csrf(client)}, follow_redirects=True)
|
||||
second_code = second.history[0].headers["location"].rsplit("code=", 1)[-1].split("&")[0]
|
||||
assert first_code != second_code
|
||||
# first code no longer resolves (rotated), second does
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == installation_uuid))
|
||||
assert enr.install_code_hash == enrollment.install_code_digest(second_code)
|
||||
anon = TestClient(client.app)
|
||||
assert anon.post("/v1/agents/enroll", json={"install_code": first_code}).status_code == 401
|
||||
assert anon.post("/v1/agents/enroll", json={"install_code": second_code}).status_code == 200
|
||||
events = [e.action for e in audits(factory)]
|
||||
assert events.count("INSTALLATION_CODE_CREATED") >= 2
|
||||
|
||||
|
||||
def test_revoke_clears_code_and_is_audited(hub, tmp_path):
|
||||
client, factory = hub
|
||||
release_id = publish_exe_release(factory, tmp_path)
|
||||
login(client)
|
||||
created = client.post("/agents/new", data={"csrf_token": csrf(client), "tenant_id": tenant_id(factory),
|
||||
"display_name": "Revoga", "release_id": release_id}, follow_redirects=False)
|
||||
installation_uuid = created.headers["location"].rsplit("/", 1)[-1]
|
||||
client.post(f"/agents/pending/{installation_uuid}/code", data={"csrf_token": csrf(client)})
|
||||
revoked = client.post(f"/agents/pending/{installation_uuid}/revoke", data={"csrf_token": csrf(client)}, follow_redirects=False)
|
||||
assert revoked.status_code == 303
|
||||
with factory() as session:
|
||||
enr = session.scalar(select(AgentEnrollment).where(AgentEnrollment.enrollment_uuid == installation_uuid))
|
||||
assert enr.status == "REVOKED" and enr.install_code_hash is None
|
||||
events = {e.action for e in audits(factory)}
|
||||
assert "INSTALLATION_CODE_CREATED" in events and "INSTALLATION_CODE_REVOKED" in events and "INSTALLATION_REVOKED" in events
|
||||
Reference in New Issue
Block a user