Files
arc53--docsgpt/tests/devices/test_submit_output_audit.py
wehub-resource-sync fed8b2eed7
Backend release / release (push) Waiting to run
Bandit Security Scan / bandit_scan (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push multi-arch DocsGPT Docker image / manifest (push) Blocked by required conditions
Build and push DocsGPT FE Docker image for development / build (linux/amd64, ubuntu-latest, amd64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / build (linux/arm64, ubuntu-24.04-arm, arm64) (push) Waiting to run
Build and push DocsGPT FE Docker image for development / manifest (push) Blocked by required conditions
Python linting / ruff (push) Waiting to run
Run python tests with pytest / Run tests and count coverage (3.12) (push) Waiting to run
React Widget Build / build (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:28:29 +08:00

207 lines
6.5 KiB
Python

"""Route-level coverage for submit_output's audit write (fix 4).
The audit outcome must come from the control chunk captured locally during
the parse loop, so it survives the draining (worker) process racing to delete
the invocation's Redis state — modelled here by the post-submit snapshot
returning None.
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
import pytest
from flask import Flask
from application.api.devices import auth as auth_module
from application.api.devices import session as session_module
@pytest.fixture
def app():
return Flask(__name__)
def _device_row() -> dict:
return {
"id": "dev_route",
"user_id": "user_route",
"name": "laptop",
"status": "active",
"approval_mode": "ask",
"machine_pubkey_fingerprint": "fp",
"token_hash": "tokhash",
}
class _Repo:
def __init__(self, _conn):
pass
def find_by_token_hash(self, _token_hash):
return _device_row()
def touch_last_seen(self, _device_id):
pass
class _Ctx:
def __enter__(self):
return None
def __exit__(self, *a):
return False
class _AuditSpy:
calls: list = []
def __init__(self, _conn):
pass
def record_result(self, invocation_id, **kwargs):
_AuditSpy.calls.append((invocation_id, kwargs))
class _RaceBroker:
"""First get_invocation (ownership check) returns a live inv; the second
(post-submit snapshot) returns ``snap`` — None models the worker having
already deleted the hash.
"""
def __init__(self, snap):
self._snap = snap
self.calls = 0
def get_invocation(self, _invocation_id):
self.calls += 1
if self.calls == 1:
return SimpleNamespace(device_id="dev_route", completed=True)
return self._snap
def submit_output_chunk(self, _invocation_id, _chunk):
return True
def submit_ack(self, _invocation_id, _decision, _reason=None):
return True
CONTROL_BODY = b'{"stream":"control","exit_code":9,"duration_ms":42}\n'
def _post(app: Flask, broker, body: bytes, invocation_id: str = "inv_audit"):
path = f"/api/devices/sessions/s/invocations/{invocation_id}/output"
with app.test_request_context(
path, method="POST", data=body, headers={"Authorization": "Bearer tok"}
):
patches = [
patch.object(auth_module, "DevicesRepository", _Repo),
patch.object(auth_module, "db_readonly", _Ctx),
patch.object(auth_module, "db_session", _Ctx),
patch.object(session_module, "get_broker", return_value=broker),
patch.object(session_module, "DeviceAuditLogRepository", _AuditSpy),
patch.object(session_module, "db_session", _Ctx),
]
for p in patches:
p.start()
try:
return session_module.submit_output("s", invocation_id)
finally:
for p in patches:
p.stop()
def test_audit_written_from_control_chunk_when_snapshot_present(app):
_AuditSpy.calls.clear()
snap = SimpleNamespace(started_at=None, stdout_bytes=3, stderr_bytes=0)
resp = _post(app, _RaceBroker(snap), CONTROL_BODY)
assert resp.status_code == 200
assert len(_AuditSpy.calls) == 1
inv_id, kw = _AuditSpy.calls[0]
assert inv_id == "inv_audit"
assert kw["exit_code"] == 9
assert kw["duration_ms"] == 42
assert kw["stdout_bytes"] == 3
def test_audit_written_even_when_snapshot_deleted_by_race(app):
# snap is None: the worker already cleaned up the hash. The functional
# outcome fields must still land, sourced from the local control chunk.
_AuditSpy.calls.clear()
resp = _post(app, _RaceBroker(None), CONTROL_BODY)
assert resp.status_code == 200
assert len(_AuditSpy.calls) == 1
_inv_id, kw = _AuditSpy.calls[0]
assert kw["exit_code"] == 9
assert kw["duration_ms"] == 42
assert kw["started_at"] is None
assert kw["stdout_bytes"] == 0
def test_no_audit_without_control_chunk(app):
_AuditSpy.calls.clear()
resp = _post(app, _RaceBroker(None), b'{"stream":"stdout","chunk":"hi"}\n')
assert resp.status_code == 200
assert _AuditSpy.calls == []
# ---------------------------------------------------------------------------
# ack_invocation: a denial is a terminal outcome with no subsequent output,
# so it must record its own audit row (the device never POSTs output).
# ---------------------------------------------------------------------------
def _ack(app: Flask, broker, decision: str, invocation_id: str = "inv_audit",
reason=None):
path = f"/api/devices/sessions/s/invocations/{invocation_id}/ack"
with app.test_request_context(
path,
method="POST",
json={"decision": decision, "reason": reason},
headers={"Authorization": "Bearer tok"},
):
patches = [
patch.object(auth_module, "DevicesRepository", _Repo),
patch.object(auth_module, "db_readonly", _Ctx),
patch.object(auth_module, "db_session", _Ctx),
patch.object(session_module, "get_broker", return_value=broker),
patch.object(session_module, "DeviceAuditLogRepository", _AuditSpy),
patch.object(session_module, "db_session", _Ctx),
]
for p in patches:
p.start()
try:
return session_module.ack_invocation("s", invocation_id)
finally:
for p in patches:
p.stop()
def test_denied_ack_records_terminal_audit_outcome(app):
_AuditSpy.calls.clear()
resp = _ack(app, _RaceBroker(None), "denied", reason="user rejected")
assert resp.status_code == 200
assert len(_AuditSpy.calls) == 1
inv_id, kw = _AuditSpy.calls[0]
assert inv_id == "inv_audit"
assert kw["error"] == "denied"
assert kw["finished_at"] is not None
# exit_code is intentionally not passed (a denied command never ran), so it
# stays NULL via record_result's COALESCE update.
assert kw.get("exit_code") is None
def test_accepted_ack_does_not_record_outcome(app):
# An accepted command will run and POST output; submit_output records the
# outcome then, so ack must not write a (premature) terminal row.
_AuditSpy.calls.clear()
resp = _ack(app, _RaceBroker(None), "accepted")
assert resp.status_code == 200
assert _AuditSpy.calls == []
def test_auto_approved_ack_does_not_record_outcome(app):
_AuditSpy.calls.clear()
resp = _ack(app, _RaceBroker(None), "auto_approved")
assert resp.status_code == 200
assert _AuditSpy.calls == []