Files

872 lines
31 KiB
Python

"""Unit tests for structural query CLI commands.
Covers: sections, entrypoints, imports, exports, symbols, strings.
Validates against VAL-STRUCT-001 through VAL-STRUCT-016.
"""
from __future__ import annotations
import json
# Add the scripts directory to the path for imports
import sys
import tempfile
from pathlib import Path
import pytest
_skill_dir = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(_skill_dir / "scripts"))
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture
def temp_workspace():
"""Create a temporary workspace directory for testing."""
with tempfile.TemporaryDirectory() as tmpdir:
# Set up workspace as the temp dir
workspace_root = Path(tmpdir)
yield workspace_root
@pytest.fixture
def project_imported(temp_workspace):
"""Create a project with an imported binary."""
import uuid
from datetime import datetime, timezone
project_id = str(uuid.uuid4())
binary_id = str(uuid.uuid4())
project_dir = temp_workspace / "test-proj"
project_dir.mkdir(parents=True, exist_ok=True)
manifest = {
"id": project_id,
"name": "test-proj",
"state": "IMPORTED",
"created_at": datetime.now(timezone.utc).isoformat(),
"updated_at": datetime.now(timezone.utc).isoformat(),
"workspace_version": "1",
"binary_count": 1,
"is_stale": False,
"current_binary": {
"id": binary_id,
"sha256": "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855",
"path": "/tmp/test.bin",
"format": "PE",
"import_mode": "copy",
"size_bytes": 16384,
"architecture": "x86",
},
}
binaries_dir = project_dir / "binaries"
binaries_dir.mkdir(exist_ok=True)
with open(binaries_dir / f"{binary_id}.json", "w") as f:
json.dump(manifest["current_binary"], f)
with open(project_dir / "project.json", "w") as f:
json.dump(manifest, f)
return project_dir
@pytest.fixture
def project_ready(project_imported):
"""Create a project in READY (analyzed) state."""
project_dir = project_imported
with open(project_dir / "project.json") as f:
manifest = json.load(f)
manifest["state"] = "READY"
with open(project_dir / "project.json", "w") as f:
json.dump(manifest, f)
return project_dir
# ---------------------------------------------------------------------------
# Import helpers
# ---------------------------------------------------------------------------
def _make_args(**kwargs):
"""Create a mock argparse.Namespace."""
defaults = {
"json": True,
"quiet": False,
"limit": None,
"timeout": 300,
"cursor": None,
"min_length": 4,
"contains": None,
"encoding": None,
"sort": "address",
}
defaults.update(kwargs)
class Args:
pass
args = Args()
for k, v in defaults.items():
setattr(args, k, v)
return args
# ---------------------------------------------------------------------------
# Test helper: cursor encoding/decoding
# ---------------------------------------------------------------------------
class TestCursorScoping:
"""Test cursor encode/decode and scope validation."""
def test_encode_decode_roundtrip(self):
from binary_analysis.cli.structural import _decode_cursor, _encode_cursor
data = {"c": "sections", "p": "proj-1", "fh": "abc", "s": "address", "o": 10}
encoded = _encode_cursor(data)
decoded = _decode_cursor(encoded)
assert decoded == data
def test_decode_invalid_cursor(self):
from binary_analysis.cli.structural import _decode_cursor
from binary_analysis.domain.errors import InvalidArgsError
with pytest.raises(InvalidArgsError):
_decode_cursor("not-valid-base64!!!")
def test_validate_cursor_scope_match(self):
import hashlib
import json
from binary_analysis.cli.structural import _validate_cursor_scope
# Compute the actual filters hash for empty filters
filters_hash = hashlib.md5(json.dumps({}, sort_keys=True).encode("utf-8")).hexdigest()
cursor_data = {"c": "sections", "p": "proj-1", "fh": filters_hash, "s": None, "o": 10}
offset = _validate_cursor_scope(
cursor_data, "sections", "proj-1", filters=None, sort_key=None
)
assert offset == 10
def test_validate_cursor_scope_mismatched_command(self):
from binary_analysis.cli.structural import _validate_cursor_scope
from binary_analysis.domain.errors import InvalidArgsError
cursor_data = {"c": "entrypoints", "p": "proj-1", "fh": "abc", "s": None, "o": 10}
with pytest.raises(InvalidArgsError, match="command"):
_validate_cursor_scope(cursor_data, "sections", "proj-1", filters=None, sort_key=None)
def test_validate_cursor_scope_mismatched_project(self):
from binary_analysis.cli.structural import _validate_cursor_scope
from binary_analysis.domain.errors import InvalidArgsError
cursor_data = {"c": "sections", "p": "proj-2", "fh": "abc", "s": None, "o": 10}
with pytest.raises(InvalidArgsError, match="project"):
_validate_cursor_scope(cursor_data, "sections", "proj-1", filters=None, sort_key=None)
def test_validate_cursor_scope_mismatched_filters(self):
from binary_analysis.cli.structural import _validate_cursor_scope
from binary_analysis.domain.errors import InvalidArgsError
cursor_data = {"c": "strings", "p": "proj-1", "fh": "abc", "s": None, "o": 10}
with pytest.raises(InvalidArgsError, match="filters"):
_validate_cursor_scope(
cursor_data,
"strings",
"proj-1",
filters={"min_length": 10},
sort_key=None, # Different filter hash
)
def test_make_cursor_scoped(self):
from binary_analysis.cli.structural import _decode_cursor, _make_cursor
cursor = _make_cursor("strings", "proj-1", 20, filters={"min_length": 10})
decoded = _decode_cursor(cursor)
assert decoded["c"] == "strings"
assert decoded["p"] == "proj-1"
assert decoded["o"] == 20
# ---------------------------------------------------------------------------
# Test: Sections
# ---------------------------------------------------------------------------
class TestSectionsCommand:
"""Tests for the 'sections' command (VAL-STRUCT-001, 002, 014)."""
def test_sections_basic(self, monkeypatch, project_ready):
"""VAL-STRUCT-001: Sections return canonical objects with pagination."""
from binary_analysis.cli.structural import execute_sections
monkeypatch.setattr(
"binary_analysis.cli.structural._resolve_project_path",
lambda _: str(project_ready),
)
args = _make_args(project="test-proj")
result = execute_sections(args)
assert result["success"] is True
assert "items" in result["data"]
assert "total" in result["data"]
assert "has_more" in result["data"]
assert "next_cursor" in result["data"]
items = result["data"]["items"]
assert len(items) > 0
# Each section must have required fields
for section in items:
assert "name" in section
assert "address" in section
assert "virtual_size" in section
assert "raw_size" in section
assert "flags" in section
assert "entropy" in section
# Address must be canonical
addr = section["address"]
assert isinstance(addr, dict)
assert "space" in addr
assert "offset" in addr
assert "display" in addr
# Flags must be list of strings
assert isinstance(section["flags"], list)
def test_sections_limit_2(self, monkeypatch, project_ready):
"""VAL-STRUCT-002: --limit 2 returns exactly 2 items with has_more=true."""
from binary_analysis.cli.structural import execute_sections
monkeypatch.setattr(
"binary_analysis.cli.structural._resolve_project_path",
lambda _: str(project_ready),
)
args = _make_args(project="test-proj", limit=2)
result = execute_sections(args)
assert result["success"] is True
assert len(result["data"]["items"]) == 2
assert result["data"]["has_more"] is True
assert result["data"]["next_cursor"] is not None
def test_sections_pagination_cursor(self, monkeypatch, project_ready):
"""Cursor from first page produces next page with no overlap."""
from binary_analysis.cli.structural import execute_sections
monkeypatch.setattr(
"binary_analysis.cli.structural._resolve_project_path",
lambda _: str(project_ready),
)
args1 = _make_args(project="test-proj", limit=2)
result1 = execute_sections(args1)
cursor = result1["data"]["next_cursor"]
items1 = result1["data"]["items"]
args2 = _make_args(project="test-proj", limit=2, cursor=cursor)
result2 = execute_sections(args2)
items2 = result2["data"]["items"]
# No overlap between pages
names1 = {s["name"] for s in items1}
names2 = {s["name"] for s in items2}
assert names1.isdisjoint(names2)
def test_sections_cursor_mismatched(self, monkeypatch, project_ready):
"""Cursor from sections used with entrypoints returns error."""
from binary_analysis.cli.structural import (
_make_cursor,
execute_entrypoints,
)
from binary_analysis.domain.errors import InvalidArgsError
monkeypatch.setattr(
"binary_analysis.cli.structural._resolve_project_path",
lambda _: str(project_ready),
)
with open(project_ready / "project.json") as f:
manifest = json.load(f)
# Create a sections cursor and try with entrypoints
sections_cursor = _make_cursor("sections", manifest["id"], 5)
args = _make_args(project="test-proj", cursor=sections_cursor)
with pytest.raises(InvalidArgsError, match="command"):
execute_entrypoints(args)
def test_sections_unanalyzed_project(self, monkeypatch, project_imported):
"""VAL-STRUCT-014: Unanalyzed project succeeds with info diagnostic."""
from binary_analysis.cli.structural import execute_sections
monkeypatch.setattr(
"binary_analysis.cli.structural._resolve_project_path",
lambda _: str(project_imported),
)
args = _make_args(project="test-proj")
result = execute_sections(args)
assert result["success"] is True
assert len(result["data"]["items"]) > 0
# Must have info-level diagnostic about incomplete analysis
diagnostics = result.get("diagnostics", [])
assert any(
d.get("severity") == "INFO" and "not been fully analyzed" in d.get("message", "")
for d in diagnostics
)
# ---------------------------------------------------------------------------
# Test: Entrypoints
# ---------------------------------------------------------------------------
class TestEntrypointsCommand:
"""Tests for the 'entrypoints' command (VAL-STRUCT-003)."""
def test_entrypoints_basic(self, monkeypatch, project_ready):
"""VAL-STRUCT-003: Entrypoints with kind and confidence."""
from binary_analysis.cli.structural import execute_entrypoints
monkeypatch.setattr(
"binary_analysis.cli.structural._resolve_project_path",
lambda _: str(project_ready),
)
args = _make_args(project="test-proj")
result = execute_entrypoints(args)
assert result["success"] is True
items = result["data"]["items"]
assert len(items) > 0
for ep in items:
assert "address" in ep
assert "kind" in ep
assert ep["kind"] in ("program", "library", "boot", "firmware", "unknown")
assert "confidence" in ep
assert ep["confidence"] in ("HIGH", "MEDIUM", "LOW", "UNKNOWN")
assert "name" in ep
def test_entrypoints_pagination(self, monkeypatch, project_ready):
"""Entrypoints pagination works."""
from binary_analysis.cli.structural import execute_entrypoints
monkeypatch.setattr(
"binary_analysis.cli.structural._resolve_project_path",
lambda _: str(project_ready),
)
args = _make_args(project="test-proj", limit=1)
result = execute_entrypoints(args)
assert result["success"] is True
assert "total" in result["data"]
assert "has_more" in result["data"]
assert "next_cursor" in result["data"]
# ---------------------------------------------------------------------------
# Test: Imports
# ---------------------------------------------------------------------------
class TestImportsCommand:
"""Tests for the 'imports' command (VAL-STRUCT-004)."""
def test_imports_basic(self, monkeypatch, project_ready):
"""VAL-STRUCT-004: Imports with module, symbol, resolution, ordinal."""
from binary_analysis.cli.structural import execute_imports
monkeypatch.setattr(
"binary_analysis.cli.structural._resolve_project_path",
lambda _: str(project_ready),
)
args = _make_args(project="test-proj")
result = execute_imports(args)
assert result["success"] is True
items = result["data"]["items"]
assert len(items) > 0
for imp in items:
assert "module" in imp
assert "symbol" in imp
assert "address" in imp
assert "resolution" in imp
assert imp["resolution"] in ("RESOLVED", "PARTIAL", "UNRESOLVED")
assert "ordinal" in imp
# ---------------------------------------------------------------------------
# Test: Exports
# ---------------------------------------------------------------------------
class TestExportsCommand:
"""Tests for the 'exports' command (VAL-STRUCT-005)."""
def test_exports_basic(self, monkeypatch, project_ready):
"""VAL-STRUCT-005: Exports with name, address, ordinal, forwarder, kind."""
from binary_analysis.cli.structural import execute_exports
monkeypatch.setattr(
"binary_analysis.cli.structural._resolve_project_path",
lambda _: str(project_ready),
)
args = _make_args(project="test-proj")
result = execute_exports(args)
assert result["success"] is True
items = result["data"]["items"]
assert len(items) > 0
for exp in items:
assert "name" in exp
assert "address" in exp
assert "ordinal" in exp
assert "forwarder" in exp # may be None
assert "kind" in exp
assert exp["kind"] in ("function", "data")
# ---------------------------------------------------------------------------
# Test: Symbols
# ---------------------------------------------------------------------------
class TestSymbolsCommand:
"""Tests for the 'symbols' command (VAL-STRUCT-006)."""
def test_symbols_basic(self, monkeypatch, project_ready):
"""VAL-STRUCT-006: Symbols with name, address, source, scope."""
from binary_analysis.cli.structural import execute_symbols
monkeypatch.setattr(
"binary_analysis.cli.structural._resolve_project_path",
lambda _: str(project_ready),
)
args = _make_args(project="test-proj")
result = execute_symbols(args)
assert result["success"] is True
items = result["data"]["items"]
assert len(items) > 0
source_values = {
"ORIGINAL",
"IMPORTED",
"DEBUG",
"BACKEND_GENERATED",
"USER_ANNOTATION",
"AGENT_SUGGESTION",
"UNKNOWN",
}
scope_values = {"global", "local", "unknown"}
for sym in items:
assert "name" in sym
assert "address" in sym
assert "source" in sym
assert sym["source"] in source_values
assert "scope" in sym
assert sym["scope"] in scope_values
def test_symbols_imported_cross_linking(self, monkeypatch, project_ready):
"""IMPORTED symbols are cross-linked to imports table."""
from binary_analysis.cli.structural import execute_symbols
monkeypatch.setattr(
"binary_analysis.cli.structural._resolve_project_path",
lambda _: str(project_ready),
)
args = _make_args(project="test-proj")
result = execute_symbols(args)
imported_symbols = [s for s in result["data"]["items"] if s.get("source") == "IMPORTED"]
# At least some symbols should be imported
# (from FakeAdapter fixtures, there are symbols with IMPORTED source)
if imported_symbols:
for sym in imported_symbols:
# Should have cross-link to import info if found
if "import" in sym:
imp_info = sym["import"]
assert "module" in imp_info
assert "symbol" in imp_info
assert "resolution" in imp_info
# ---------------------------------------------------------------------------
# Test: Strings
# ---------------------------------------------------------------------------
class TestStringsCommand:
"""Tests for the 'strings' command (VAL-STRUCT-007, 008, 009, 010, 016)."""
def test_strings_basic(self, monkeypatch, project_ready):
"""VAL-STRUCT-007: Strings with text, encoding, address, length."""
from binary_analysis.cli.structural import execute_strings
monkeypatch.setattr(
"binary_analysis.cli.structural._resolve_project_path",
lambda _: str(project_ready),
)
args = _make_args(project="test-proj")
result = execute_strings(args)
assert result["success"] is True
items = result["data"]["items"]
assert len(items) > 0
for s in items:
assert "text" in s
assert "encoding" in s
assert s["encoding"] in ("ASCII", "UTF-8", "UTF-16")
assert "address" in s
assert "length" in s
assert isinstance(s["length"], int)
def test_strings_min_length(self, monkeypatch, project_ready):
"""VAL-STRUCT-008: --min-length excludes short strings."""
from binary_analysis.cli.structural import execute_strings
monkeypatch.setattr(
"binary_analysis.cli.structural._resolve_project_path",
lambda _: str(project_ready),
)
# Get all strings first
args_all = _make_args(project="test-proj", min_length=1)
result_all = execute_strings(args_all)
# Now filter with min-length=10
args_filtered = _make_args(project="test-proj", min_length=10)
result_filtered = execute_strings(args_filtered)
# Every item in filtered result must have length >= 10
for s in result_filtered["data"]["items"]:
assert s["length"] >= 10
# Total count must be <= unfiltered count
assert result_filtered["data"]["total"] <= result_all["data"]["total"]
# Must report applied_filters
assert "applied_filters" in result_filtered["data"]
filters = result_filtered["data"]["applied_filters"]
assert any(f["filter"] == "min_length" for f in filters)
def test_strings_contains(self, monkeypatch, project_ready):
"""VAL-STRUCT-009: --contains returns substring matches."""
from binary_analysis.cli.structural import execute_strings
monkeypatch.setattr(
"binary_analysis.cli.structural._resolve_project_path",
lambda _: str(project_ready),
)
args = _make_args(project="test-proj", contains="Error")
result = execute_strings(args)
# Every item must contain the substring
for s in result["data"]["items"]:
assert "Error" in s["text"]
# Must report applied_filters
assert "applied_filters" in result["data"]
filters = result["data"]["applied_filters"]
assert any(f["filter"] == "contains" and f["value"] == "Error" for f in filters)
def test_strings_combined_filters(self, monkeypatch, project_ready):
"""VAL-STRUCT-010: Combined --contains + --min-length apply both."""
from binary_analysis.cli.structural import execute_strings
monkeypatch.setattr(
"binary_analysis.cli.structural._resolve_project_path",
lambda _: str(project_ready),
)
args = _make_args(project="test-proj", contains="GetProc", min_length=5)
result = execute_strings(args)
# Every item must satisfy both filters
for s in result["data"]["items"]:
assert "GetProc" in s["text"]
assert s["length"] >= 5
# Both filters in applied_filters
assert "applied_filters" in result["data"]
filters = result["data"]["applied_filters"]
assert any(f["filter"] == "contains" for f in filters)
assert any(f["filter"] == "min_length" for f in filters)
def test_strings_unicode_preservation(self, monkeypatch, project_ready):
"""VAL-STRUCT-016: Unicode strings preserved in valid JSON output.
We monkeypatch the FakeAdapter.get_strings to include Unicode strings
and verify the JSON output is reparsable by python3 -m json.tool.
"""
import binary_analysis.cli.structural as struct_mod
from binary_analysis.cli.structural import execute_strings
from binary_analysis.domain.entities import Address, String
# Monkeypatch project resolution
monkeypatch.setattr(
"binary_analysis.cli.structural._resolve_project_path",
lambda _: str(project_ready),
)
original_get_adapter = struct_mod._get_adapter_and_binary
def _patched_get_adapter_and_binary(project_path, manifest):
adapter, binary, proj_info = original_get_adapter(project_path, manifest)
# Monkeypatch get_strings to return unicode test strings
unicode_strings = [
String(
text="你好世界",
encoding="UTF-8",
address=Address(space="ram", offset="0x500000", display="0x500000"),
length=8,
),
String(
text="😀🎉💻",
encoding="UTF-8",
address=Address(space="ram", offset="0x500008", display="0x500008"),
length=6,
),
String(
text="שָׁלוֹם",
encoding="UTF-8",
address=Address(space="ram", offset="0x500010", display="0x500010"),
length=5,
),
String(
text='He said "hello"',
encoding="ASCII",
address=Address(space="ram", offset="0x500018", display="0x500018"),
length=15,
),
String(
text="C:\\path\\to\\file",
encoding="ASCII",
address=Address(space="ram", offset="0x500028", display="0x500028"),
length=16,
),
]
# Override get_strings on the adapter instance
def patched_get_strings(binary, min_length=4, contains=None, encoding_filter=None):
result = []
for s in unicode_strings:
if s.length < min_length:
continue
if contains is not None and contains not in s.text:
continue
if encoding_filter is not None and s.encoding != encoding_filter:
continue
result.append(s)
return result
adapter.get_strings = patched_get_strings
return adapter, binary, proj_info
struct_mod._get_adapter_and_binary = _patched_get_adapter_and_binary
try:
args = _make_args(project="test-proj", min_length=1)
result = execute_strings(args)
assert result["success"] is True
# Convert to JSON and verify it's reparsable
json_str = json.dumps(result, ensure_ascii=False)
parsed = json.loads(json_str)
# Verify JSON validates with json.tool
import subprocess
proc = subprocess.run(
["python3", "-m", "json.tool"],
input=json_str,
capture_output=True,
text=True,
)
assert proc.returncode == 0, f"JSON validation failed: {proc.stderr}"
# Check that unicode strings survived the roundtrip
items = parsed["data"]["items"]
texts = {s["text"] for s in items}
assert "你好世界" in texts
assert "😀🎉💻" in texts
assert "שָׁלוֹם" in texts
assert 'He said "hello"' in texts
assert "C:\\path\\to\\file" in texts
finally:
struct_mod._get_adapter_and_binary = original_get_adapter
def test_strings_pagination_cursor_scope_filters(self, monkeypatch, project_ready):
"""VAL-STRUCT-015: Cursor from different filter set returns error."""
from binary_analysis.cli.structural import execute_strings
from binary_analysis.domain.errors import InvalidArgsError
monkeypatch.setattr(
"binary_analysis.cli.structural._resolve_project_path",
lambda _: str(project_ready),
)
# Get cursor with min_length=4 (default)
args1 = _make_args(project="test-proj", limit=1, min_length=4)
result1 = execute_strings(args1)
cursor = result1["data"]["next_cursor"]
# Try using cursor with min_length=10 (different filter)
args2 = _make_args(project="test-proj", limit=1, min_length=10, cursor=cursor)
with pytest.raises(InvalidArgsError, match="filters"):
execute_strings(args2)
def test_strings_contains_no_match(self, monkeypatch, project_ready):
"""--contains with no matches returns empty result."""
from binary_analysis.cli.structural import execute_strings
monkeypatch.setattr(
"binary_analysis.cli.structural._resolve_project_path",
lambda _: str(project_ready),
)
args = _make_args(project="test-proj", contains="ZZZZNOMATCHZZZZ")
result = execute_strings(args)
assert result["success"] is True
assert result["data"]["total"] == 0
assert result["data"]["items"] == []
# ---------------------------------------------------------------------------
# Test: Binary not found
# ---------------------------------------------------------------------------
class TestErrorHandling:
"""Test error handling for structural commands."""
def test_binary_not_found(self, tmp_path):
"""Project with no binary returns appropriate error."""
import json as _json
from datetime import datetime, timezone
from binary_analysis.cli.structural import execute_sections
from binary_analysis.domain.errors import BinaryNotFoundError
project_dir = tmp_path / "empty-proj"
project_dir.mkdir()
manifest = {
"id": "test-id",
"name": "empty-proj",
"state": "CREATED",
"created_at": datetime.now(timezone.utc).isoformat(),
"updated_at": datetime.now(timezone.utc).isoformat(),
"workspace_version": "1",
"binary_count": 0,
"is_stale": False,
}
with open(project_dir / "project.json", "w") as f:
_json.dump(manifest, f)
import binary_analysis.cli.structural as struct_mod
original_resolve = struct_mod._resolve_project_path
struct_mod._resolve_project_path = lambda _: str(project_dir)
try:
args = _make_args(project="empty-proj")
with pytest.raises(BinaryNotFoundError):
execute_sections(args)
finally:
struct_mod._resolve_project_path = original_resolve
def test_project_not_found(self):
"""Non-existent project returns error."""
from binary_analysis.cli.structural import execute_sections
from binary_analysis.domain.errors import ProjectNotFoundError
args = _make_args(project="nonexistent-12345")
with pytest.raises(ProjectNotFoundError):
execute_sections(args)
# ---------------------------------------------------------------------------
# Test: JSON format compliance
# ---------------------------------------------------------------------------
class TestJsonFormat:
"""Test JSON output format compliance."""
def test_json_envelope_has_required_fields(self, monkeypatch, project_ready):
"""All structural commands return valid JSON with standard envelope."""
from binary_analysis.cli.structural import (
execute_entrypoints,
execute_exports,
execute_imports,
execute_sections,
execute_strings,
execute_symbols,
)
monkeypatch.setattr(
"binary_analysis.cli.structural._resolve_project_path",
lambda _: str(project_ready),
)
commands = {
"sections": execute_sections,
"entrypoints": execute_entrypoints,
"imports": execute_imports,
"exports": execute_exports,
"symbols": execute_symbols,
"strings": execute_strings,
}
for _cmd_name, cmd_fn in commands.items():
args = _make_args(project="test-proj", limit=2)
result = cmd_fn(args)
# Core result fields
assert "success" in result
assert "partial" in result
assert "warnings" in result
assert "diagnostics" in result
assert "data" in result
# Data pagination fields
assert "items" in result["data"]
assert "total" in result["data"]
assert "has_more" in result["data"]
assert "next_cursor" in result["data"]
# Items should be lists
assert isinstance(result["data"]["items"], list)
assert isinstance(result["data"]["total"], int)
assert isinstance(result["data"]["has_more"], bool)