security(discovery): enforce Zone isolation and retire unsafe legacy writers

Bind Agent observations, review, enrichment and telemetry to current Zone
and inventory authority. Preserve immutable MAC ownership and historical
snapshots with stale-result rejection and transactional security audits.

Retire unbound direct/range/scheduled discovery and one/bulk legacy summary
writes in both modes. Require reconciled exact inventory identity or
explicit Zone-scoped IP selection for CSV metadata imports.

Independent Blocker #1 application-security re-audit: PASS on tree
cf863aadc9. Evidence: 141 focused PostgreSQL/HTTP
tests passed; Chromium and PowerShell checks passed. Two documented baseline
source assertions remain unchanged. Production deployment is not approved.
This commit is contained in:
lussierj committed 2026-10-17 00:45:04 +00:00
1 parent e898f283ab
commit 8b0d32cd6b
26 files changed
+3798 -4092

No files matched your search

+22
View File
@@ -10,6 +10,8 @@ from psycopg.types.json import Jsonb
from app.agent_commands.exceptions import AgentCommandConflict, AgentCommandNotFound
from app.agent_commands.policy import READ_ONLY_COMMAND_TYPES, rejection_execution_outcome, rejection_reason
from app.config import settings
from app.discovery.repository import DiscoveryRepository
from fastapi import HTTPException
ConnectionFactory = Callable[[], psycopg.Connection]
@@ -120,9 +122,29 @@ class AgentCommandRepository:
connection = self._connection_factory()
try:
with connection.cursor(row_factory=dict_row) as cur:
DiscoveryRepository.lock(cur)
self._retire_unsupported(cur, agent_id)
discovery = DiscoveryRepository(self._connection_factory)
cur.execute(f"""SELECT q.*, ({NO_DELIVERY_EVIDENCE_SQL}) AS no_delivery_evidence
FROM minerops_agent_command_queue q WHERE agent_id=%s
AND command_status='pending' AND command_type=ANY(%s)
ORDER BY created_at,id FOR UPDATE SKIP LOCKED LIMIT 1000""",
(agent_id,list(READ_ONLY_COMMAND_TYPES)))
pending=list(cur.fetchall())
for command in pending:
try:
discovery.validate_command(cur,agent_id,command)
except HTTPException as exc:
cur.execute("""UPDATE minerops_agent_command_queue SET command_status='cancelled',
completed_at=COALESCE(completed_at,now()), error_message=concat_ws(chr(10),NULLIF(error_message,''),%s::text)
WHERE id=%s""",(exc.detail,command['id']))
DiscoveryRepository.audit(cur,'discovery.command_rejected',None,
{'command_id':command['id'],'agent_id':agent_id,'reason':exc.detail,
'execution_outcome':rejection_execution_outcome({**command,'previous_status':'pending'})})
cur.execute(CLAIM_COMMANDS_SQL, (agent_id, list(READ_ONLY_COMMAND_TYPES), limit))
rows = [dict(row) for row in cur.fetchall()]
for row in rows:
discovery.validate_command(cur,agent_id,row)
connection.commit()
return rows
except Exception:
+5
View File
@@ -108,6 +108,11 @@ class AgentZoneRoutingContext(BaseModel):
routing_reason: str | None = None
class AgentDiscoveryMinerMatch(AgentZoneRoutingContext):
miner_id: UUID | None = None
matched_by: str | None = None
class MinerZoneAssociationResult(BaseModel):
agent_zone_id: UUID | None = None
routing_reason: str | None = None
+19 -9
View File
@@ -204,6 +204,8 @@ class AgentZoneRepository:
ip_address = EXCLUDED.ip_address,
last_checkin_at = now(),
last_payload = EXCLUDED.last_payload
WHERE minerops_site_agents.site_id IS NOT DISTINCT FROM EXCLUDED.site_id
AND minerops_site_agents.agent_zone_id IS NOT DISTINCT FROM EXCLUDED.agent_zone_id
""",
(
agent_id,
@@ -216,6 +218,8 @@ class AgentZoneRepository:
Jsonb(payload),
),
)
if cur.rowcount != 1:
raise AgentZoneConflict("Agent assignment changed concurrently")
connection.commit()
except UniqueViolation as exc:
connection.rollback()
@@ -294,6 +298,20 @@ class AgentZoneRepository:
finally:
connection.close()
def find_miner_in_zone(self, *, agent_id: str, agent_zone_id: UUID,
mac_address: str | None, ip_address: str | None) -> dict | None:
"""Retained helper uses the same atomic authority/collision boundary as HTTP."""
from app.discovery.repository import DiscoveryRepository, reject
repository = DiscoveryRepository(self._connection_factory)
with repository.transaction() as cur:
context = repository.context(cur, agent_id)
if context['agent_zone_id'] != agent_zone_id:
reject('discovery_stale_authority')
if not mac_address and not ip_address:
return None
row = repository.match(cur, context, {'mac_address': mac_address, 'ip_address': ip_address})
return {'id': row['id'], 'matched_by': 'mac' if mac_address else 'ip'} if row else None
def assign_miners_to_zone(
self, miner_ids: Iterable[UUID], agent_zone_id: UUID
) -> dict[UUID, str]:
@@ -326,15 +344,7 @@ class AgentZoneRepository:
elif miner["site_id"] not in {None, zone["site_id"]}:
outcomes[miner_id] = "site_conflict"
else:
cur.execute(
"""
UPDATE miners
SET site_id = %s, agent_zone_id = %s, updated_at = now()
WHERE id = %s AND agent_zone_id IS NULL
""",
(zone["site_id"], agent_zone_id, miner_id),
)
outcomes[miner_id] = "assigned"
outcomes[miner_id] = "miner_zone_unassigned"
connection.commit()
return outcomes
except Exception:
+27
View File
@@ -10,6 +10,7 @@ from app.agent_zones.exceptions import (
from app.agent_zones.models import (
AgentCheckinRequest,
AgentCheckinResponse,
AgentDiscoveryMinerMatch,
AgentZoneRoutingContext,
AgentZoneCreateRequest,
AgentZoneSummary,
@@ -81,6 +82,8 @@ class AgentZoneService:
self.validate_agent_identity(authenticated_agent_id, request.agent_id)
existing = self.repository.get_agent(authenticated_agent_id)
if not existing or existing.get("agent_zone_id") is None:
raise AgentZoneValidationError("Agent requires an administrator-established Zone assignment")
requested_site = request.site_id
requested_zone = request.agent_zone_id
final_site = requested_site or (existing or {}).get("site_id")
@@ -199,6 +202,30 @@ class AgentZoneService:
],
}
def match_discovered_miner(
self,
agent_id: str,
*,
mac_address: str | None,
ip_address: str | None,
recorded_zone_id: UUID | None = None,
) -> AgentDiscoveryMinerMatch:
routing = self.routing_context(agent_id, recorded_zone_id)
if routing.routing_reason or routing.agent_zone_id is None:
return AgentDiscoveryMinerMatch(**routing.model_dump())
match = self.repository.find_miner_in_zone(
agent_id=agent_id,
agent_zone_id=routing.agent_zone_id,
mac_address=mac_address,
ip_address=ip_address,
)
return AgentDiscoveryMinerMatch(
**routing.model_dump(),
miner_id=match["id"] if match else None,
matched_by=match["matched_by"] if match else None,
)
def associate_miners_from_agent(
self, agent_id: str, miner_ids: list[UUID]
) -> MinerZoneAssociationResult:
+1
View File
@@ -0,0 +1 @@
"""Agent discovery isolation; physical miner writes are never authorized here."""
+96
View File
@@ -0,0 +1,96 @@
"""Exact administrative CSV selection, inside the caller's locked transaction.
CSV metadata never establishes ownership or replaces board identity. Every supplied
identity is a constraint; a failed strong identifier never falls back to an IP.
"""
from uuid import UUID
from fastapi import HTTPException
from app.discovery.repository import address, mac
def resolve_inventory_row(cur, row, row_number, selected_zone=None):
def fail(message, status=409):
raise HTTPException(status_code=status, detail=f"CSV row {row_number}: {message}")
def field(names, normalize=lambda v: v):
values = []
for name in names:
value = row.get(name)
if value is not None and str(value).strip():
try:
values.append(normalize(str(value).strip()))
except (ValueError, HTTPException):
fail(f"invalid {names[0]}", 422)
if len(set(values)) > 1:
fail(f"conflicting {names[0]} columns")
return values[0] if values else None
def uuid(value):
return str(UUID(value))
mid = field(["Miner ID", "Miner UUID", "miner_id", "id"], uuid)
zone = field(["Agent Zone ID", "Zone ID", "agent_zone_id", "zone_id"], uuid)
site = field(["Site ID", "site_id"], uuid)
ip = field(["IP", "IP Address", "ip", "ip_address"], address)
current_mac = field(["MAC Addr", "MAC", "Mac", "mac", "mac_address"], mac)
serial = field(["Serial", "Serial Number", "SN", "Miner SN", "miner_sn", "serial_number"])
if selected_zone:
try:
selected_zone = uuid(selected_zone)
except ValueError:
fail("invalid selected Agent Zone ID", 422)
if zone and zone != selected_zone:
fail("row Zone disagrees with selected Zone")
zone = selected_zone
if zone:
cur.execute("SELECT site_id::text FROM agent_zones WHERE id=%s", (zone,))
zone_row = cur.fetchone()
if not zone_row:
fail("selected Zone does not exist", 422)
if site and site != zone_row["site_id"]:
fail("Site and Zone disagree")
if not (mid or current_mac or serial or (ip and zone)):
fail("provide Miner ID, current MAC, serial, or IP plus Agent Zone ID", 422)
selectors = []
if mid:
selectors.append(("id=%s::uuid", mid))
if current_mac:
selectors.append(("regexp_replace(lower(current_mac), '[^0-9a-f]', '', 'g')=%s", current_mac.replace(":", "").lower()))
if serial:
selectors.append(("miner_sn=%s", serial))
# IP is only a namespace-scoped selector when no stronger identity is supplied.
if not selectors:
selectors.append(("current_ip=%s::inet AND agent_zone_id=%s::uuid", (ip, zone)))
candidates = {}
for predicate, value in selectors:
params = value if isinstance(value, tuple) else (value,)
cur.execute(
"SELECT id::text, site_id::text, agent_zone_id::text, "
"host(current_ip) AS current_ip, current_mac, miner_sn, model, miner_type, firmware_version "
"FROM miners WHERE " + predicate + " ORDER BY id LIMIT 2 FOR UPDATE",
params,
)
matches = cur.fetchall()
if len(matches) > 1:
fail("ambiguous inventory identity; supply exact Miner ID and consistent identifiers")
candidates.update({m["id"]: m for m in matches})
if not candidates:
return None
if len(candidates) != 1:
fail("identifiers select different inventory records")
miner = next(iter(candidates.values()))
if (
(mid and mid != miner["id"])
or (zone and zone != miner["agent_zone_id"])
or (site and site != miner["site_id"])
or (ip and ip != miner["current_ip"])
or (serial and serial != miner["miner_sn"])
or (current_mac and current_mac != mac(miner["current_mac"]))
):
fail("identifiers disagree with current inventory; refresh selection before importing")
return miner
+42
View File
@@ -0,0 +1,42 @@
"""Bounded HTTP adapter; authentication remains on the registered routes."""
import json
from fastapi import HTTPException
from starlette.concurrency import run_in_threadpool
import psycopg
from app.discovery.repository import DiscoveryRepository
MAX_BODY = 1024 * 1024
async def dispatch(method, request=None, **kwargs):
if request is not None:
chunks = []
size = 0
async for chunk in request.stream():
size += len(chunk)
if size > MAX_BODY:
raise HTTPException(413, "discovery_payload_too_large")
chunks.append(chunk)
try:
def invalid_constant(_):
raise ValueError("Non-finite JSON number")
kwargs["body"] = json.loads(
b"".join(chunks), parse_constant=invalid_constant
)
except (ValueError, UnicodeDecodeError, RecursionError):
raise HTTPException(422, "discovery_invalid_json")
try:
return await run_in_threadpool(getattr(DiscoveryRepository(), method), **kwargs)
except (
psycopg.errors.LockNotAvailable,
psycopg.errors.QueryCanceled,
psycopg.errors.DeadlockDetected,
):
raise HTTPException(409, "discovery_concurrent_change_retry")
except psycopg.DataError:
raise HTTPException(422, "discovery_invalid_value")
except psycopg.IntegrityError:
raise HTTPException(409, "discovery_identity_conflict")
File diff suppressed because it is too large. Load diff
+3
View File
@@ -711,6 +711,7 @@ def machine_agent_request(path: str) -> bool:
"/api/agent/commands/pending-disabled-old",
"/api/agent/commands/result-disabled-old",
"/api/agent/discoveries",
"/api/agent/discovery-target",
"/api/agent/miner-enrichment",
"/api/agent/miner-enrichment-v2",
"/api/agent/miner-enrichment-v3",
@@ -826,6 +827,8 @@ def required_api_permission(method: str, path: str) -> str | None:
return "repairs.complete"
if method == "GET" and repair_timeline_request(path):
return "repairs.read"
if method == "GET" and path in {"/api/agent/discoveries/summary", "/api/agent/status-summary"}:
return "agents.view"
if method == "GET":
if path.startswith("/api/system/"):
return "system_health.view"
+199 -3942
View File
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,49 @@
// Actual Chromium; every HTTP request is fulfilled or aborted, with disposable data only.
const {chromium}=require('playwright');
const fs=require('fs');const assert=require('node:assert/strict');
(async()=>{
const browser=await chromium.launch({headless:true,args:['--no-sandbox','--disable-background-networking']});
try {
const context=await browser.newContext({serviceWorkers:'block'});
const page=await context.newPage();
const marker='<img src="data:," onerror="window.__discoveryAudit=1">';
const commands=[];const unexpected=[];
const observations=[0,1].map(n=>({id:'00000000-0000-4000-8000-00000000000'+(n+1),agent_id:'Agent '+n,agent_zone_id:'Zone '+n,version:'2026-01-01T00:00:00.00000'+n+'+00:00',created_at:'2026-01-01T00:00:00Z',ip_address:'198.51.100.10',room_name:marker,model:marker,mac_address:marker,discovery_status:'new'}));
await page.route('**/*',async route=>{
const url=new URL(route.request().url());
if(url.origin!=='http://minerops.invalid'){unexpected.push(url.href);return route.abort();}
const path=url.pathname;
if(path==='/agent/control')return route.fulfill({contentType:'text/html',body:fs.readFileSync(process.argv[2],'utf8')});
if(path==='/api/agent/status-summary')return route.fulfill({json:{live_database_counts:{total:2,online:2},latest_snapshot:{},agent:{agent_id:marker,hostname:marker,version:marker},recent_commands:[{command_type:marker,command_status:'failed',requested_by_username:marker,error_message:marker}]}});
if(path==='/api/agent/discoveries')return route.fulfill({json:{discoveries:observations}});
if(path==='/api/agent/discoveries/summary')return route.fulfill({json:{total:2,counts:{new:2}}});
if(path==='/api/agent/discoveries/review'||path==='/api/agent/discoveries/enrich-match'){
commands.push({path,body:route.request().postDataJSON()});return route.fulfill({json:{ok:true,message:'Saved'}});
}
return route.abort();
});
await context.addInitScript(()=>localStorage.setItem('minerops_token','disposable-browser-token'));
page.on('dialog',dialog=>dialog.accept());
await page.goto('http://minerops.invalid/agent/control');
await page.waitForSelector('#discoveriesBody tr button');
if(process.argv[3]==='baseline'){
await page.waitForFunction(()=>window.__discoveryAudit===1);
console.log('Baseline positive control: harmless stored event handler executed.');
} else {
assert.equal(await page.evaluate(()=>window.__discoveryAudit),undefined);
assert.equal(await page.locator('#discoveriesBody img,#commandsBody img,[onerror]').count(),0);
assert.ok((await page.locator('#discoveriesBody').innerText()).includes(marker));
await page.locator('#discoveriesBody tr').nth(0).getByRole('button',{name:'Add as New',exact:true}).click();
await page.waitForFunction(()=>document.getElementById('statusBox').textContent==='Agent status loaded.');
await page.locator('#discoveriesBody tr').nth(1).getByRole('button',{name:'Enrich / Match',exact:true}).click();
assert.equal(commands.length,2);
assert.equal(commands[0].body.discovery_id,observations[0].id);
assert.equal(commands[0].body.version,observations[0].version);
assert.equal(commands[1].body.discovery_id,observations[1].id);
assert.equal(commands[1].body.version,observations[1].version);
assert.ok(commands.every(c=>!Object.hasOwn(c.body,'ip_address')));
assert.deepEqual(unexpected,[]);
console.log('Chromium passed: stored markup inert; Agent/status/error data escaped; same-IP actions retain exact observation IDs/versions; no external requests.');
}
} finally {await browser.close();}
})().catch(e=>{console.error(e);process.exitCode=1});
@@ -0,0 +1,20 @@
"""Export the literal production page without importing/startup or network access."""
import ast, json, sys
from pathlib import Path
root = Path(sys.argv[1])
tree = ast.parse((root / "backend/app/main.py").read_text())
node = next(
n
for n in tree.body
if isinstance(n, ast.AsyncFunctionDef) and n.name == "minerops_agent_control_page"
)
html = next(
n.value
for n in ast.walk(node)
if isinstance(n, ast.Constant)
and isinstance(n.value, str)
and "<!doctype html>" in n.value
)
Path(sys.argv[2]).write_text(html)
+20 -83
View File
@@ -264,30 +264,13 @@ if __name__ == "__main__":
class AgentTransportIsolationSourceTests(unittest.TestCase):
def test_known_miners_and_pool_snapshots_are_agent_zone_scoped(self):
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
identity = (Path(__file__).parents[1] / "app" / "identity.py").read_text()
known_start = main.index('@app.get("/api/agent/known-miners")')
known_end = main.index("# Phase 5J:", known_start)
known = main[known_start:known_end]
pool_start = main.index('@app.post("/api/agent/pool-snapshots")')
pool_end = main.index("\n@app.", pool_start + 1)
pool = main[pool_start:pool_end]
self.assertIn('"/api/agent/known-miners"', identity)
self.assertIn("Depends(require_agent_identity)", known)
self.assertIn("zone_service.routing_context", known)
self.assertIn("miners.agent_zone_id = $1::uuid", known)
self.assertNotIn("minerops_require_agent_secret", known)
self.assertIn("Depends(require_agent_identity)", pool)
self.assertIn("zone_service.routing_context", pool)
self.assertIn("agent_id = authenticated_agent_id", pool)
self.assertIn("agent_zone_id = %s", pool)
self.assertNotIn('body.get("agent_id")', pool)
self.assertNotIn("MINEROPS_AGENT_SHARED_SECRET", pool)
from app.main import minerops_agent_known_miners, agent_pool_snapshots
from inspect import getsource
for fn,method in [(minerops_agent_known_miners,"known"),(agent_pool_snapshots,"snapshots")]:
source=getsource(fn)
self.assertIn("Depends(require_agent_identity)",source)
self.assertIn("discovery_dispatch",source)
self.assertIn("agent_id=authenticated_agent_id",source)
def test_windows_machine_pollers_send_agent_identity(self):
root = agent_source_root()
@@ -307,67 +290,21 @@ class AgentTransportIsolationSourceTests(unittest.TestCase):
class AgentEnrichmentIsolationSourceTests(unittest.TestCase):
def test_enrichment_routes_require_agent_identity_and_zone_scope(self):
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
decorators = (
'@app.post("/api/agent/miner-enrichment")',
'@app.post("/api/agent/miner-enrichment-v2")',
'@app.post("/api/agent/miner-enrichment-v3")',
)
for decorator in decorators:
with self.subTest(decorator=decorator):
start = main.index(decorator)
end = main.index("\n@app.", start + 1)
route = main[start:end]
self.assertIn("Depends(require_agent_identity)", route)
self.assertIn("zone_service.routing_context", route)
self.assertIn("agent_zone_id", route)
self.assertNotIn("minerops_require_agent_secret", route)
v2_start = main.index('@app.post("/api/agent/miner-enrichment-v2")')
v2_end = main.index("\n@app.", v2_start + 1)
v2 = main[v2_start:v2_end]
self.assertIn(
"AND agent_zone_id = $2::uuid",
v2,
)
self.assertIn(
"AND agent_zone_id = $3::uuid",
v2,
)
v3_start = main.index('@app.post("/api/agent/miner-enrichment-v3")')
v3_end = main.index("\n@app.", v3_start + 1)
v3 = main[v3_start:v3_end]
self.assertIn(
"AND agent_zone_id = $8::uuid",
v3,
)
self.assertIn(
"AND agent_zone_id = $7::uuid",
v3,
)
from app import main
from inspect import getsource
for fn in [main.minerops_agent_miner_enrichment,main.minerops_agent_miner_enrichment_v2,main.minerops_agent_miner_enrichment_v3]:
source=getsource(fn)
self.assertIn("Depends(require_agent_identity)",source)
self.assertIn("discovery_dispatch",source)
self.assertIn("agent_id=authenticated_agent_id",source)
def test_api3_uses_authenticated_identity_and_never_global_agent_identity(self):
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
start = main.index('@app.post("/api/agent/api3-enrichment")')
end = main.index("\n@app.", start + 1)
route = main[start:end]
self.assertIn("Depends(require_agent_identity)", route)
self.assertIn("zone_service.routing_context", route)
self.assertIn("agent_id = authenticated_agent_id", route)
self.assertIn("agent_zone_id = %s", route)
self.assertIn("if miner_id is None:", route)
self.assertIn("continue", route)
self.assertNotIn("MINEROPS_AGENT_SHARED_SECRET", route)
self.assertNotIn('body.get("agent_id")', route)
self.assertNotIn('body.get("site_name")', route)
self.assertNotIn('request.headers.get("x-agent-secret")', route)
from app.main import minerops_agent_api3_enrichment
from inspect import getsource
source=getsource(minerops_agent_api3_enrichment)
self.assertIn("Depends(require_agent_identity)",source)
self.assertIn("discovery_dispatch",source)
self.assertIn("agent_id=authenticated_agent_id",source)
def test_legacy_config_remains_explicitly_legacy_only(self):
main = (Path(__file__).parents[1] / "app" / "main.py").read_text()
@@ -0,0 +1,39 @@
"""Reused discovery isolation scenarios exercised through real HTTP authority."""
import unittest
from tests.test_discovery_security_postgres import DiscoveryFixtures
class AgentDiscoveryIsolationPostgresTests(DiscoveryFixtures, unittest.TestCase):
def test_cross_zone_mac_and_ip_are_unmatched_without_id_disclosure(self):
mid, mac = self.miner(1)
before = self.durable()
row = self.observe(mac=mac)
self.assertEqual(row["discovery_status"], "zone_conflict")
self.assertIsNone(row["matched_miner_id"])
self.assertNotIn(str(mid), str(row))
self.assertEqual(before["miners"], self.durable()["miners"])
def test_same_zone_mac_and_ip_matches_are_preserved(self):
mid, mac = self.miner()
row = self.observe(mac=mac)
self.assertEqual(row["matched_miner_id"], str(mid))
self.assertEqual(row["discovery_status"], "known")
def test_unassigned_miner_is_not_claimed_by_discovery_matching(self):
mid, mac = self.miner()
with __import__(
"app.agent_zones.repository", fromlist=["default_connection_factory"]
).default_connection_factory() as conn:
conn.execute("UPDATE miners SET agent_zone_id=NULL WHERE id=%s", (mid,))
row = self.observe(mac=mac)
self.assertIsNone(row["matched_miner_id"])
self.assertIsNone(
self.scalar("SELECT agent_zone_id FROM miners WHERE id=%s", (mid,))
)
def test_claimed_agent_identity_cannot_override_authentication(self):
response = self.post(
"/api/agent/discoveries", {"agent_id": self.agents[1], "discoveries": []}
)
self.assertEqual(response.status_code, 403)
+53 -28
View File
@@ -42,6 +42,7 @@ class FakeRepository:
self.agent = None
self.saved = []
self.assign_result = {MINER_A: "assigned"}
self.discovery_match = None
def get_agent(self, agent_id):
return self.agent if self.agent and self.agent["agent_id"] == agent_id else None
@@ -107,6 +108,11 @@ class FakeRepository:
],
}
def find_miner_in_zone(self, *, agent_id, agent_zone_id, mac_address, ip_address):
self.discovery_match_request = {'agent_id':agent_id,'agent_zone_id':agent_zone_id,
'mac_address':mac_address,'ip_address':ip_address}
return self.discovery_match
def assign_miners_to_zone(self, miner_ids, agent_zone_id):
self.assigned = (list(miner_ids), agent_zone_id)
return {miner_id: self.assign_result.get(miner_id, "miner_not_found")
@@ -136,6 +142,7 @@ class AgentZoneModelTests(unittest.TestCase):
class AgentZoneServiceTests(unittest.TestCase):
def setUp(self):
self.repository = FakeRepository()
self.repository.agent = {"agent_id":"agent-a","site_id":SITE_A,"agent_zone_id":ZONE_A,"site_name":"North"}
self.service = AgentZoneService(self.repository)
def checkin(self, **kwargs):
@@ -144,14 +151,14 @@ class AgentZoneServiceTests(unittest.TestCase):
AgentCheckinRequest(agent_id="agent-a", site_name="North", **kwargs),
)
def test_explicit_first_assignment_and_same_assignment_are_allowed(self):
def test_established_assignment_and_same_assignment_are_allowed(self):
first = self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
second = self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
self.assertTrue(first.routing_available)
self.assertEqual(second.agent_zone_id, ZONE_A)
def test_explicit_zone_site_mismatch_and_identity_spoof_are_rejected(self):
with self.assertRaisesRegex(AgentZoneValidationError, "does not belong"):
with self.assertRaisesRegex(AgentZoneValidationError, "cannot change Sites"):
self.checkin(site_id=SITE_B, agent_zone_id=ZONE_A)
with self.assertRaisesRegex(AgentZoneValidationError, "authenticated"):
self.service.checkin(
@@ -181,34 +188,20 @@ class AgentZoneServiceTests(unittest.TestCase):
self.assertEqual(result.agent_zone_id, ZONE_A)
def test_transitional_resolution_requires_exactly_one_enabled_zone(self):
single = self.checkin(site_id=SITE_A)
self.assertEqual(single.agent_zone_id, ZONE_A)
self.repository.agent = None
self.repository.zones = {}
zero = self.checkin(site_id=SITE_A)
self.assertEqual(zero.routing_reason, "agent_zone_unavailable")
self.repository.agent = None
self.repository.zones = {
ZONE_A: zone(),
ZONE_B: zone(ZONE_B, SITE_A, name="Room 2"),
}
many = self.checkin(site_id=SITE_A)
self.assertEqual(many.routing_reason, "agent_zone_ambiguous")
self.assertIsNone(many.agent_zone_id)
for zones in [{}, {ZONE_A: zone()}, {ZONE_A:zone(),ZONE_B:zone(ZONE_B,SITE_A)}]:
self.repository.zones=zones
self.repository.agent=None
with self.assertRaisesRegex(AgentZoneValidationError,"administrator-established"):
self.checkin(site_id=SITE_A)
def test_disabled_zone_is_not_selected_and_existing_assignment_is_factual(self):
self.repository.zones[ZONE_A]["is_enabled"] = False
unassigned = self.checkin(site_id=SITE_A)
self.assertEqual(unassigned.routing_reason, "agent_zone_unavailable")
self.repository.agent = {
"agent_id": "agent-a", "site_id": SITE_A,
"agent_zone_id": ZONE_A, "site_name": "North",
}
existing = self.service.checkin(
"agent-a", AgentCheckinRequest(agent_id="agent-a")
)
self.assertEqual(existing.routing_reason, "agent_zone_disabled")
self.assertEqual(existing.agent_zone_id, ZONE_A)
self.repository.zones[ZONE_A]["is_enabled"]=False
existing=self.service.checkin("agent-a",AgentCheckinRequest(agent_id="agent-a"))
self.assertEqual(existing.routing_reason,"agent_zone_disabled")
self.assertEqual(existing.agent_zone_id,ZONE_A)
self.repository.agent=None
with self.assertRaisesRegex(AgentZoneValidationError,"administrator-established"):
self.checkin(site_id=SITE_A)
def test_payload_is_sanitized_before_persistence(self):
request = AgentCheckinRequest.model_validate({
@@ -255,9 +248,41 @@ class AgentZoneServiceTests(unittest.TestCase):
self.assertNotIn("10.80.0.0/24", str(configuration))
def test_discovery_match_uses_only_authenticated_agent_zone(self):
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
self.repository.discovery_match = {"id": MINER_A, "matched_by": "mac"}
result = self.service.match_discovered_miner(
"agent-a",
mac_address="02:00:00:00:16:21",
ip_address="192.0.2.21",
)
self.assertEqual(result.miner_id, MINER_A)
self.assertEqual(result.matched_by, "mac")
self.assertEqual(
self.repository.discovery_match_request["agent_zone_id"], ZONE_A
)
def test_discovery_match_rejects_stale_recorded_zone_without_querying(self):
self.checkin(site_id=SITE_A, agent_zone_id=ZONE_A)
result = self.service.match_discovered_miner(
"agent-a",
recorded_zone_id=ZONE_B,
mac_address="02:00:00:00:16:21",
ip_address="192.0.2.21",
)
self.assertIsNone(result.miner_id)
self.assertEqual(result.routing_reason, "agent_zone_conflict")
self.assertFalse(hasattr(self.repository, "discovery_match_request"))
class AgentZoneRouterTests(unittest.TestCase):
def setUp(self):
self.repository = FakeRepository()
self.repository.agent = {"agent_id":"agent-a","site_id":SITE_A,"agent_zone_id":ZONE_A,"site_name":"North"}
self.service = AgentZoneService(self.repository)
self.app = FastAPI()
self.app.include_router(router)
+3 -3
View File
@@ -196,12 +196,12 @@ class AgentZonePostgresTests(unittest.TestCase):
connection.rollback()
connection.close()
def test_unassigned_miner_inherits_zone_and_existing_other_zone_wins(self):
def test_unassigned_miner_stays_unassigned_and_existing_other_zone_wins(self):
repository = AgentZoneRepository(connect)
outcomes = repository.assign_miners_to_zone(
[MINER_UNASSIGNED, MINER_OTHER], ZONE_A
)
self.assertEqual(outcomes[MINER_UNASSIGNED], "assigned")
self.assertEqual(outcomes[MINER_UNASSIGNED], "miner_zone_unassigned")
self.assertEqual(outcomes[MINER_OTHER], "agent_zone_conflict")
connection = connect()
try:
@@ -213,7 +213,7 @@ class AgentZonePostgresTests(unittest.TestCase):
assignments = dict(cur.fetchall())
finally:
connection.close()
self.assertEqual(assignments[MINER_UNASSIGNED], ZONE_A)
self.assertIsNone(assignments[MINER_UNASSIGNED])
self.assertEqual(assignments[MINER_OTHER], ZONE_B)
def test_disabled_zone_blocks_control_without_site_fallback(self):
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,338 @@
"""Audit F1-F3 reversals: real registered HTTP/transactions, no physical devices."""
import ast
from concurrent.futures import ThreadPoolExecutor
import os
from pathlib import Path
import threading
import time
import unittest
from unittest.mock import patch
from uuid import uuid4
from fastapi import HTTPException
import app.main as main
from app.identity import create_access_token
from tests.test_discovery_security_postgres import DiscoveryFixtures, connect
class LegacyInventorySecurityTests(DiscoveryFixtures, unittest.TestCase):
def upload(self, text, query='', headers=None):
return self.client.post(
'/api/imports/whatsminer-tool-scan?dry_run=false&update_existing=true' + query,
headers=self.admin if headers is None else headers,
files={'file': ('simulated.csv', text.encode(), 'text/csv')},
)
def denied(self, response, before, status):
self.assertEqual(response.status_code, status, response.text)
self.assertEqual(before, self.durable())
self.transport.assert_not_called()
def test_discovery_audit_reproductions_all_rejected_without_reads(self):
self.miner(1, ip='198.51.100.111')
self.miner(ip='198.51.100.125')
self.miner(1, ip='198.51.100.125')
for mode in ['true', 'false']:
for ip in ['198.51.100.111', '198.51.100.125', '203.0.113.209']:
with self.subTest(mode=mode, ip=ip), patch.dict(os.environ, MINEROPS_HUB_MODE=mode):
before = self.durable()
response = self.client.post('/api/miners/discover-one', headers=self.admin, json={'ip_address': ip})
self.denied(response, before, 410)
def test_missing_disabled_agent_and_zone_cannot_restore_legacy_discovery(self):
with connect() as c:
c.execute('UPDATE agent_zones SET is_enabled=false WHERE id=%s', (self.zones[0],))
c.execute('UPDATE minerops_site_agents SET credential_revoked_at=now() WHERE agent_id=%s', (self.agents[0],))
c.execute('DELETE FROM minerops_site_agents WHERE agent_id=%s', (self.agents[1],))
before = self.durable()
r = self.client.post('/api/miners/discover-one', headers=self.admin, json={'ip_address': '198.51.100.111'})
self.denied(r, before, 410)
def test_inventory_only_permission_cannot_replace_mac(self):
role, uid = uuid4(), uuid4()
with connect() as c:
c.execute('INSERT INTO roles(id,name) VALUES (%s,%s)', (role, str(role)))
c.execute("INSERT INTO role_permissions(role_id,permission_id) SELECT %s,id FROM permissions WHERE key='miners.update'", (role,))
c.execute("INSERT INTO users(id,username,display_name,password_hash,role_id) VALUES (%s,%s,'Inventory only','unused',%s)", (uid, str(uid), role))
headers = {'Authorization': 'Bearer ' + create_access_token(str(uid), str(uid), str(role))}
mid, _ = self.miner(ip='198.51.100.127')
before = self.durable()
for path, body, status in [
('/api/agent/discoveries/review', {'discovery_id': str(uuid4()), 'version': 'x', 'action': 'ignore'}, 403),
(f'/api/miners/{mid}/replace-control-board', {}, 403),
('/api/miners/discover-one', {'ip_address': '198.51.100.127'}, 410),
]:
self.denied(self.client.post(path, headers=headers, json=body), before, status)
def test_range_and_manual_scheduler_retired_in_both_modes(self):
for mode in ['true', 'false']:
with patch.dict(os.environ, MINEROPS_HUB_MODE=mode):
for path in [f'/api/scan-ranges/{uuid4()}/discover-now', '/api/scanner/run-known-now']:
before = self.durable()
self.denied(self.client.post(path, headers=self.admin, json={}), before, 410)
with patch('app.main.threading.Thread') as thread:
main.start_scheduled_scanner()
thread.assert_not_called()
status = self.client.get('/api/scanner/status', headers=self.admin).json()['data']
self.assertFalse(status['enabled'])
self.assertTrue(status['scanner_disabled'])
def test_all_legacy_helper_definitions_and_saved_aliases_are_retired(self):
# Execute every original definition, including shadowed functions; decorators
# are omitted so this cannot mutate the application's registered routes.
tree = ast.parse(Path(main.__file__).read_text())
names = {'find_existing_miner', 'discover_one_miner', 'discover_scan_range_now',
'call_discover_one_miner_flexible', 'refresh_one_known_miner_for_schedule',
'run_scheduled_known_miner_scan', 'scheduled_scanner_loop',
'apply_legacy_summary_to_miner', 'legacy_find_miner_id_by_ip'}
import inspect
for node in ast.walk(tree):
if isinstance(node, ast.FunctionDef) and node.name in names:
with self.subTest(helper=node.name, line=node.lineno):
node.decorator_list = []
namespace = dict(vars(main))
exec(compile(ast.Module(body=[node], type_ignores=[]), '<retired-helper>', 'exec'), namespace)
fn = namespace[node.name]
kwargs = {name: None for name, p in inspect.signature(fn).parameters.items() if p.default is inspect.Parameter.empty}
with patch('app.main.get_db_connection', side_effect=AssertionError('No legacy database access')):
with self.assertRaises(HTTPException) as exc:
fn(**kwargs)
self.assertEqual(exc.exception.status_code, 410)
with self.assertRaises(HTTPException) as exc:
main._minerops_original_discover_one_miner_legacy_summary(None)
self.assertEqual(exc.exception.status_code, 410)
def test_summary_id_ip_disagreement_and_bulk_retired(self):
a, _ = self.miner(ip='198.51.100.121')
self.miner(1, ip='198.51.100.122')
with patch('app.main.read_legacy_whatsminer_summary', side_effect=AssertionError('No summary transport')) as read:
for mode in ['true', 'false']:
with patch.dict(os.environ, MINEROPS_HUB_MODE=mode):
for path, body in [
('/api/tools/legacy-summary-refresh-one', {'miner_id': str(a), 'ip_address': '198.51.100.122'}),
('/api/tools/legacy-summary-refresh-one', {'miner_id': str(a)}),
('/api/tools/legacy-summary-refresh-missing', {}),
]:
before = self.durable()
self.denied(self.client.post(path, headers=self.admin, json=body), before, 410)
read.assert_not_called()
def test_summary_cannot_start_inflight_read_or_overwrite_newer_owner(self):
mid, _ = self.miner(ip='198.51.100.123')
with patch('app.main.read_legacy_whatsminer_summary') as read, ThreadPoolExecutor(2) as pool:
future = pool.submit(lambda: self.client.post('/api/tools/legacy-summary-refresh-one', headers=self.admin, json={'miner_id': str(mid)}))
with connect() as c:
c.execute("UPDATE miners SET site_id=%s,agent_zone_id=%s,current_ip='198.51.100.124',updated_at=clock_timestamp() WHERE id=%s", (self.sites[1], self.zones[1], mid))
self.assertEqual(future.result(timeout=8).status_code, 410)
before = self.durable()
with self.assertRaises(HTTPException):
main.apply_legacy_summary_to_miner(str(mid), '198.51.100.123')
self.assertEqual(before, self.durable())
self.assertEqual(self.scalar('SELECT host(current_ip) FROM miners WHERE id=%s', (mid,)), '198.51.100.124')
read.assert_not_called()
def test_legacy_human_auth_still_required(self):
for headers in [{}, self.headers()]:
for path in ['/api/miners/discover-one', '/api/tools/legacy-summary-refresh-one']:
before = self.durable()
self.denied(self.client.post(path, headers=headers, json={'ip_address': '198.51.100.111'}), before, 401)
def test_csv_ip_only_requires_zone_even_with_one_global_match(self):
self.miner(1, ip='198.51.100.112')
before = self.durable()
self.denied(self.upload('IP,Miner Type\n198.51.100.112,WRONG\n'), before, 422)
def test_csv_same_ip_requires_namespace_and_exact_zone_updates_one(self):
a, _ = self.miner(ip='198.51.100.126')
b, _ = self.miner(1, ip='198.51.100.126')
before = self.durable()
self.denied(self.upload('IP,Miner Type\n198.51.100.126,WRONG\n'), before, 422)
r = self.upload(f'IP,Agent Zone ID,Miner Type\n198.51.100.126,{self.zones[1]},VALID\n')
self.assertEqual(r.status_code, 200, r.text)
self.assertIsNone(self.scalar('SELECT miner_type FROM miners WHERE id=%s', (a,)))
self.assertEqual(self.scalar('SELECT miner_type FROM miners WHERE id=%s', (b,)), 'VALID')
self.assertEqual(before['miner_status_snapshots'], self.durable()['miner_status_snapshots'])
def test_csv_duplicate_ip_inside_zone_rejected(self):
self.miner(ip='198.51.100.130'); self.miner(ip='198.51.100.130')
before = self.durable()
self.denied(self.upload('IP,Miner Type\n198.51.100.130,WRONG\n', '&agent_zone_id=' + str(self.zones[0])), before, 409)
def test_csv_exact_id_mac_serial_and_query_zone_legitimate(self):
mid, mac = self.miner(ip='198.51.100.131')
serial = uuid4().hex
with connect() as c:
c.execute('UPDATE miners SET miner_sn=%s WHERE id=%s', (serial, mid))
for heading, value in [('Miner ID', str(mid)), ('MAC', mac), ('Serial', serial), ('IP', '198.51.100.131')]:
r = self.upload(f'{heading},Miner Type\n{value},VALID\n', '&agent_zone_id=' + str(self.zones[0]))
self.assertEqual(r.status_code, 200, r.text)
self.assertEqual(r.json()['data']['matched_count'], 1)
def test_csv_conflicting_and_unknown_mac_never_fall_back_to_ip(self):
a, _ = self.miner(ip='198.51.100.132')
b, bm = self.miner(1, ip='198.51.100.133')
for mac in [bm, '02:AA:BB:CC:DD:EF']:
before = self.durable()
self.denied(self.upload(f'Miner ID,IP,MAC,Miner Type\n{a},198.51.100.132,{mac},WRONG\n'), before, 409)
before = self.durable()
self.denied(self.upload(f'Miner ID,IP,Miner Type\n{a},198.51.100.133,WRONG\n'), before, 409)
def test_csv_historical_mac_and_contradictory_namespace_serial_denied(self):
mid, oldmac = self.miner(ip='198.51.100.134')
with connect() as c:
c.execute('UPDATE miners SET current_mac=%s,miner_sn=%s WHERE id=%s', ('02:' + ':'.join(f'{v:02X}' for v in uuid4().bytes[:5]), 'serial-' + str(mid), mid))
for columns, values in [
('MAC', oldmac), ('Serial', 'unknown'), ('Site ID', str(self.sites[1])),
('Agent Zone ID', str(self.zones[1])), ('miner_id', str(uuid4())),
]:
before = self.durable()
self.denied(self.upload(f'Miner ID,{columns},Miner Type\n{mid},{values},WRONG\n'), before, 409)
def test_csv_invalid_identifiers_and_query_row_disagreement(self):
mid, _ = self.miner(ip='198.51.100.135')
for header, value in [('MAC', 'bad-mac'), ('IP', '198.51.100.135/24'), ('Miner ID', 'bad-id'), ('Agent Zone ID', 'bad-zone')]:
before = self.durable()
self.denied(self.upload(f'{header},Miner Type\n{value},WRONG\n'), before, 422)
before = self.durable()
self.denied(self.upload(f'Miner ID,Agent Zone ID,Miner Type\n{mid},{self.zones[1]},WRONG\n', '&agent_zone_id=' + str(self.zones[0])), before, 409)
def test_csv_unmatched_strong_identity_does_not_select_ip(self):
self.miner(ip='198.51.100.136')
before = self.durable()
r = self.upload('MAC,IP,Miner Type\n02:AA:BB:CC:DD:EF,198.51.100.136,WRONG\n')
self.assertEqual(r.status_code, 200, r.text)
self.assertEqual(r.json()['data']['unmatched_count'], 1)
self.assertEqual(before['miners'], self.durable()['miners'])
def test_csv_batch_conflict_rolls_back_prior_valid_row(self):
a, _ = self.miner(ip='198.51.100.137')
b, _ = self.miner(1, ip='198.51.100.138')
before = self.durable()
self.denied(self.upload(f'Miner ID,IP,Miner Type\n{a},198.51.100.137,VALID\n{b},198.51.100.137,WRONG\n'), before, 409)
def test_csv_preview_repeat_and_history_preservation(self):
mid, _ = self.miner(ip='198.51.100.139')
with connect() as c:
c.execute('INSERT INTO miner_status_snapshots(id,miner_id,observed_at,mhs_av) VALUES (%s,%s,now(),123)', (uuid4(), mid))
before = self.durable()
text = f'Miner ID,Miner Type\n{mid},VALID\n'
r = self.client.post('/api/imports/whatsminer-tool-scan?dry_run=true&update_existing=true', headers=self.admin, files={'file': ('simulated.csv', text.encode(), 'text/csv')})
self.assertEqual(r.status_code, 200, r.text)
self.assertEqual(before, self.durable())
for _ in range(2):
self.assertEqual(self.upload(text).status_code, 200)
self.assertEqual(before['miner_status_snapshots'], self.durable()['miner_status_snapshots'])
self.assertEqual(before['miner_mac_ownerships'], self.durable()['miner_mac_ownerships'])
def test_csv_actual_audit_permission_failure_rolls_back(self):
mid, _ = self.miner(ip='198.51.100.140')
role = 'b1_csv_' + uuid4().hex
with connect() as c:
c.execute('CREATE ROLE ' + role)
c.execute('GRANT USAGE ON SCHEMA public TO ' + role)
c.execute('GRANT SELECT,INSERT,UPDATE,DELETE ON ALL TABLES IN SCHEMA public TO ' + role)
c.execute('REVOKE INSERT ON audit_logs FROM ' + role)
def limited():
c = connect(); c.execute('SET ROLE ' + role); c.commit(); return c
before = self.durable()
try:
with patch('app.main.get_db_connection', side_effect=limited):
r = self.upload(f'Miner ID,Miner Type\n{mid},ROLLBACK\n')
self.denied(r, before, 500)
self.assertIn('audit_logs', r.text)
finally:
with connect() as c:
c.execute('DROP OWNED BY ' + role); c.execute('DROP ROLE ' + role)
def test_csv_concurrent_ownership_move_is_revalidated_after_real_lock_wait(self):
mid, _ = self.miner(ip='198.51.100.141')
owner = connect()
owner.execute('UPDATE miners SET site_id=%s,agent_zone_id=%s,updated_at=clock_timestamp() WHERE id=%s', (self.sites[1], self.zones[1], mid))
with ThreadPoolExecutor(1) as pool:
future = pool.submit(lambda: self.upload(f'Miner ID,Agent Zone ID,Miner Type\n{mid},{self.zones[0]},WRONG\n'))
try:
deadline = time.monotonic() + 4
waiting = 0
while time.monotonic() < deadline:
waiting = self.scalar("SELECT count(*) FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock'")
if waiting: break
time.sleep(.02)
self.assertGreater(waiting, 0)
owner.commit()
r = future.result(timeout=8)
self.assertEqual(r.status_code, 409, r.text)
self.assertEqual(self.scalar('SELECT agent_zone_id FROM miners WHERE id=%s', (mid,)), self.zones[1])
self.assertIsNone(self.scalar('SELECT miner_type FROM miners WHERE id=%s', (mid,)))
finally:
owner.rollback(); owner.close()
def test_csv_real_update_lock_prevents_mid_transaction_ownership_change(self):
import app.discovery.csv_import as module
mid, _ = self.miner(ip='198.51.100.142')
entered, release = threading.Event(), threading.Event()
original = module.resolve_inventory_row
def gate(*args, **kwargs):
row = original(*args, **kwargs); entered.set()
self.assertTrue(release.wait(4)); return row
def move():
with connect() as c:
c.execute('UPDATE miners SET site_id=%s,agent_zone_id=%s,updated_at=clock_timestamp() WHERE id=%s', (self.sites[1], self.zones[1], mid))
with patch.object(module, 'resolve_inventory_row', side_effect=gate), ThreadPoolExecutor(2) as pool:
upload = pool.submit(lambda: self.upload(f'Miner ID,Agent Zone ID,Miner Type\n{mid},{self.zones[0]},VALID\n'))
self.assertTrue(entered.wait(4)); moving = pool.submit(move)
try:
deadline = time.monotonic() + 3
waiting = 0
while time.monotonic() < deadline:
waiting = self.scalar("SELECT count(*) FROM pg_stat_activity WHERE datname=current_database() AND wait_event_type='Lock'")
if waiting: break
time.sleep(.02)
self.assertGreater(waiting, 0)
finally:
release.set()
self.assertEqual(upload.result(timeout=8).status_code, 200)
moving.result(timeout=8)
self.assertEqual(self.scalar('SELECT agent_zone_id FROM miners WHERE id=%s', (mid,)), self.zones[1])
def test_csv_duplicate_headers_and_conflicting_aliases_rejected(self):
mid, _ = self.miner(ip='198.51.100.143')
for text, code in [
(f'Miner ID,Miner ID,Miner Type\n{mid},{mid},WRONG\n', 422),
(f'Miner ID,miner_id,Miner Type\n{mid},{uuid4()},WRONG\n', 409),
(f'Miner ID,Site ID,Agent Zone ID,Miner Type\n{mid},{self.sites[1]},{self.zones[0]},WRONG\n', 409),
]:
before = self.durable()
self.denied(self.upload(text), before, code)
def test_csv_duplicate_serial_is_ambiguous(self):
a, _ = self.miner(ip='198.51.100.144')
b, _ = self.miner(1, ip='198.51.100.145')
serial = 'duplicate-' + uuid4().hex
with connect() as c:
c.execute('UPDATE miners SET miner_sn=%s WHERE id IN (%s,%s)', (serial, a, b))
before = self.durable()
self.denied(self.upload(f'Serial,Miner Type\n{serial},WRONG\n'), before, 409)
def test_csv_global_mac_selection_with_overlapping_ip_is_valid(self):
a, _ = self.miner(ip='198.51.100.146')
b, mac = self.miner(1, ip='198.51.100.146')
before = self.durable()
r = self.upload(f'MAC,IP,Miner Type\n{mac},198.51.100.146,VALID\n')
self.assertEqual(r.status_code, 200, r.text)
self.assertIsNone(self.scalar('SELECT miner_type FROM miners WHERE id=%s', (a,)))
self.assertEqual(self.scalar('SELECT miner_type FROM miners WHERE id=%s', (b,)), 'VALID')
after = self.durable()
for table in ['miner_status_snapshots', 'miner_mac_ownerships', 'miner_network_identities']:
self.assertEqual(before[table], after[table])
audit = self.scalar("SELECT new_value_json FROM audit_logs WHERE actor_user_id=%s AND action='import.whatsminertool_csv.applied' ORDER BY created_at DESC LIMIT 1", (self.uid,))
self.assertEqual(audit['updates'][0]['miner_id'], str(b))
self.assertEqual(audit['updates'][0]['agent_zone_id'], str(self.zones[1]))
def test_csv_read_only_user_cannot_apply_metadata(self):
uid = uuid4()
with connect() as c:
c.execute("INSERT INTO users(id,username,display_name,password_hash,role_id) SELECT %s,%s,'Read only','unused',id FROM roles WHERE name='Read Only'", (uid, str(uid)))
headers = {'Authorization': 'Bearer ' + create_access_token(str(uid), str(uid), 'Read Only')}
mid, _ = self.miner(ip='198.51.100.147')
before = self.durable()
self.denied(self.upload(f'Miner ID,Miner Type\n{mid},WRONG\n', headers=headers), before, 403)
@@ -146,8 +146,23 @@ class StageAQueueContainmentTests(unittest.TestCase):
self.assertEqual(self.audits(command_id), [])
def test_read_only_commands_still_claim_and_complete(self):
for kind in ("poll_known_miners_now", "discover_new_miners", "enrich_match_discovery"):
self.seed(f"stage-a-read-{kind}", command_type=kind, payload={"ip_address": "192.0.2.1"})
from uuid import uuid4
from app.discovery.repository import DiscoveryRepository
site, zone, actor = uuid4(), uuid4(), uuid4()
with connect() as connection, connection.cursor() as cur:
cur.execute("INSERT INTO sites(id,name) VALUES (%s,%s)",(site,str(site)))
cur.execute("INSERT INTO agent_zones(id,site_id,name) VALUES (%s,%s,'Stage A reader')",(zone,site))
cur.execute("INSERT INTO agent_zone_network_ranges(agent_zone_id,network) VALUES (%s,'192.0.2.0/24')",(zone,))
cur.execute("""INSERT INTO minerops_site_agents(agent_id,site_id,agent_zone_id,site_name)
VALUES (%s,%s,%s,'Stage A reader') ON CONFLICT(agent_id) DO UPDATE SET
site_id=EXCLUDED.site_id,agent_zone_id=EXCLUDED.agent_zone_id""",(AGENT,site,zone))
cur.execute("INSERT INTO users(id,username,display_name,password_hash,role_id) SELECT %s,%s,'Reader','unused',id FROM roles WHERE name='Admin'",(actor,str(actor)))
discovery=DiscoveryRepository(connect)
observation=discovery.ingest(AGENT,{'discoveries':[{'ip_address':'192.0.2.1'}]})['discoveries'][0]
user={'id':str(actor),'username':'stage-a-reader'}
for kind in ('poll_known_miners_now','discover_new_miners'):
discovery.queue({'agent_id':AGENT},user,kind)
discovery.queue({'discovery_id':observation['id'],'version':observation['version']},user)
commands = self.service.claim(AGENT)
self.assertEqual(len(commands), 3)
for command in commands: