blob: e80b70be4a3851edc07047e7f1f4b9078bd0f10c [file]
# Copyright 2026 The Fuchsia Authors. All rights reserved.
# Use of this source code is governed by a BSD-style license that can be
# found in the LICENSE file.
"""Tests for the interactive consent flow."""
import json
import tempfile
import unittest
from pathlib import Path
from typing import Any
from driver_lab.api import EXIT_PERMISSION, DriverLab
from driver_lab.consent import ConsentDecision
from driver_lab.models import AccessClass, AccessRequest, Decision
from driver_lab.permissions import load_grants
from driver_lab.transport import FakeProxyTarget, ProxyDescription, ResourceInfo
CTRL_DIGEST = "sha256:" + "ab" * 32
DESCRIPTION_DIGEST = "sha256:" + "ee" * 32
POLICY_DIGEST = "sha256:" + "ff" * 32
TARGET_SCOPE = "example-engineering-target"
NODE_ID = "example-device"
def make_description() -> ProxyDescription:
return ProxyDescription(
protocol_major=1,
protocol_minor=0,
proxy_generation=7,
boot_id="boot-1",
resource_digest=DESCRIPTION_DIGEST,
policy_digest=POLICY_DIGEST,
resources=(
ResourceInfo(
id=1, name="control", logical_size=0x100, digest=CTRL_DIGEST
),
),
max_snapshot_items=64,
audit_capacity=1024,
)
def make_plan(**overrides: object) -> dict[str, Any]:
plan: dict[str, Any] = {
"schema_version": 1,
"run_id": "run-1",
"case_id": "case-1",
"target": {"selector": "lab-target"},
"node": {"id": NODE_ID, "expected_resource_digest": DESCRIPTION_DIGEST},
"access": {"mode": "proxy", "activation": "bind-unclaimed"},
"operations": [
{"kind": "mmio_read32", "resource": "control", "offset": "0x3c"},
],
}
plan.update(overrides)
return plan
class ScriptedPrompt:
"""Consent prompt returning scripted decisions and recording calls."""
def __init__(self, decision: ConsentDecision) -> None:
self.decision = decision
self.requests: list[AccessRequest] = []
self.warnings: list[str] = []
async def request_consent(
self, request: AccessRequest, warning: str
) -> ConsentDecision:
self.requests.append(request)
self.warnings.append(warning)
return self.decision
class ConsentFlowTest(unittest.IsolatedAsyncioTestCase):
def setUp(self) -> None:
self._dir = tempfile.TemporaryDirectory()
self.addCleanup(self._dir.cleanup)
base = Path(self._dir.name)
self.grants_path = base / "grants.toml"
self.evidence_root = base / "evidence"
self.fake = FakeProxyTarget(make_description())
self.fake.set_value(1, 0x3C, 0xDEAD_BEEF)
def make_lab(self, prompt: ScriptedPrompt | None) -> DriverLab:
return DriverLab(
self.fake,
grants_path=self.grants_path,
evidence_root=self.evidence_root,
target_scope=TARGET_SCOPE,
node_id=NODE_ID,
consent=prompt,
)
def resolution_sources(self, evidence_dir: Path) -> list[str]:
rows = json.loads(
(evidence_dir / "permission-resolution.json").read_text()
)
return [row["source"] for row in rows]
async def test_prompt_receives_exact_rule_and_warning(self) -> None:
prompt = ScriptedPrompt(ConsentDecision.ALLOW_ONCE)
await self.make_lab(prompt).run_plan(make_plan())
[request] = prompt.requests
self.assertEqual(request.resource, "control")
self.assertEqual(request.offset, 0x3C)
self.assertEqual(request.access, AccessClass.READ_ONCE)
self.assertEqual(request.resource_digest, CTRL_DIGEST)
self.assertIn("clear status bits", prompt.warnings[0])
async def test_allow_once_succeeds_without_persisting(self) -> None:
prompt = ScriptedPrompt(ConsentDecision.ALLOW_ONCE)
result = await self.make_lab(prompt).run_plan(make_plan())
self.assertTrue(result.ok, result.failure)
self.assertEqual(result.reads[0].value, 0xDEAD_BEEF)
self.assertEqual(load_grants(self.grants_path), [])
self.assertIn(
"allow_once", self.resolution_sources(result.evidence_dir)
)
async def test_always_allow_persists_exact_rule(self) -> None:
prompt = ScriptedPrompt(ConsentDecision.ALWAYS_ALLOW)
result = await self.make_lab(prompt).run_plan(make_plan())
self.assertTrue(result.ok, result.failure)
[grant] = load_grants(self.grants_path)
self.assertEqual(grant.offset, 0x3C)
self.assertEqual(grant.decision, Decision.ALLOW)
self.assertEqual(grant.approval_source, "interactive")
# A second run resolves from the persistent grant without prompting.
second_prompt = ScriptedPrompt(ConsentDecision.DENY_ONCE)
second = await self.make_lab(second_prompt).run_plan(
make_plan(run_id="run-2")
)
self.assertTrue(second.ok, second.failure)
self.assertEqual(second_prompt.requests, [])
self.assertIn(
"persistent", self.resolution_sources(second.evidence_dir)
)
async def test_deny_once_fails_without_persisting(self) -> None:
prompt = ScriptedPrompt(ConsentDecision.DENY_ONCE)
result = await self.make_lab(prompt).run_plan(make_plan())
self.assertEqual(result.exit_category, EXIT_PERMISSION)
self.assertEqual(result.failure, "denied by operator")
self.assertEqual(self.fake.open_attempts, 0)
self.assertEqual(load_grants(self.grants_path), [])
async def test_always_deny_persists_and_skips_future_prompts(self) -> None:
prompt = ScriptedPrompt(ConsentDecision.ALWAYS_DENY)
result = await self.make_lab(prompt).run_plan(make_plan())
self.assertEqual(result.exit_category, EXIT_PERMISSION)
[grant] = load_grants(self.grants_path)
self.assertEqual(grant.decision, Decision.DENY)
second_prompt = ScriptedPrompt(ConsentDecision.ALWAYS_ALLOW)
second = await self.make_lab(second_prompt).run_plan(
make_plan(run_id="run-2")
)
self.assertEqual(second.exit_category, EXIT_PERMISSION)
self.assertEqual(second.failure, "denied by persistent grant")
self.assertEqual(second_prompt.requests, [])
async def test_duplicate_offsets_prompt_once(self) -> None:
prompt = ScriptedPrompt(ConsentDecision.ALLOW_ONCE)
plan = make_plan(
operations=[
{
"kind": "mmio_read32",
"resource": "control",
"offset": "0x3c",
},
{
"kind": "mmio_read32",
"resource": "control",
"offset": "0x3c",
},
]
)
result = await self.make_lab(prompt).run_plan(plan)
self.assertTrue(result.ok, result.failure)
self.assertEqual(len(prompt.requests), 1)
async def test_no_prompt_fails_closed(self) -> None:
result = await self.make_lab(None).run_plan(make_plan())
self.assertEqual(result.exit_category, EXIT_PERMISSION)
self.assertEqual(
result.failure,
"consent required; unattended operation fails closed",
)
self.assertIn(
"fail_closed", self.resolution_sources(result.evidence_dir)
)
if __name__ == "__main__":
unittest.main()