Skip to content

Commit a591837

Browse files
test: add asset-scanning (AM 2.0) integration coverage
Cover the asset scanning feature (DAM/AM 2.0) against both the normal org and the AM 2.0 (DAM-enabled) org: - normal-org scan tests (test_06_asset): upload returns _asset_scan_status 'pending'; field absent unless include_asset_scan_status=true; clean file scans 'clean'; EICAR test file scans 'quarantined'; listing includes status - test_31_am_assets: AM-org assets get 'am'-prefixed UIDs, full CRUD round-trip, the same scan lifecycle, publish() with the api_version: 3.2 header (publish- only; 404 on fetch) - framework: am_stack fixture (stack in AM_ORG_UID; whole suite skips when unset), runtime-generated EICAR fixture (base64-encoded so the signature is not committed raw), wait_for_scan() polling helper, and api_version header reset for per-test isolation Note: the correct query param is include_asset_scan_status (the response field is _asset_scan_status); verified live.
1 parent 49912d4 commit a591837

4 files changed

Lines changed: 210 additions & 1 deletion

File tree

tests/integration/api/test_06_asset.py

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,52 @@ def test_specific_asset_type_requires_type(self, stack):
179179
stack.assets().specific_asset_type(None)
180180

181181

182+
class TestAssetScan:
183+
"""Asset scanning (AM 2.0) — verified enabled on the normal ORGANIZATION too.
184+
185+
The scan status is exposed only when include_asset_scan_status=true is passed;
186+
the response field is _asset_scan_status with values pending -> clean | quarantined.
187+
"""
188+
189+
def test_upload_returns_pending(self, stack):
190+
asset = stack.assets()
191+
asset.add_param("include_asset_scan_status", "true")
192+
resp = asset.upload(_ASSET_PATH)
193+
h.assert_status(resp, 201)
194+
status = h.body(resp).get("asset", {}).get("_asset_scan_status")
195+
h.tracked_assert(status, "scan status on upload").equals("pending")
196+
197+
def test_scan_status_absent_without_param(self, stack):
198+
# The field must be absent unless the include param is passed.
199+
created = h.body(stack.assets().upload(_ASSET_PATH)).get("asset", {})
200+
resp = stack.assets(created["uid"]).fetch()
201+
h.assert_status(resp, 200)
202+
h.tracked_assert(
203+
"_asset_scan_status" not in h.body(resp).get("asset", {}), "field absent w/o param"
204+
).equals(True)
205+
206+
def test_clean_asset_scanned_clean(self, stack):
207+
created = h.body(stack.assets().upload(_ASSET_PATH)).get("asset", {})
208+
status = h.wait_for_scan(stack, created["uid"], "clean")
209+
h.tracked_assert(status, "clean file scan result").equals("clean")
210+
211+
def test_malware_asset_quarantined(self, stack, eicar_file):
212+
created = h.body(stack.assets().upload(eicar_file)).get("asset", {})
213+
status = h.wait_for_scan(stack, created["uid"], "quarantined")
214+
h.tracked_assert(status, "EICAR scan result").equals("quarantined")
215+
216+
def test_find_includes_scan_status(self, stack):
217+
query = stack.assets()
218+
query.add_param("include_asset_scan_status", "true")
219+
resp = query.find()
220+
h.assert_status(resp, 200)
221+
assets = h.body(resp).get("assets", [])
222+
if assets:
223+
h.tracked_assert(
224+
"_asset_scan_status" in assets[0], "scan status in listing"
225+
).equals(True)
226+
227+
182228
class TestAssetDelete:
183229
def test_delete(self, stack):
184230
created = h.body(stack.assets().upload(_ASSET_PATH))
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""
2+
AM 2.0 (DAM 2.0) asset tests — run only against the AM-enabled org (AM_ORG_UID).
3+
4+
What's AM 2.0-specific vs the normal-org scan tests (test_06):
5+
- asset UIDs are 'am'-prefixed (vs 'blt')
6+
- the `api_version: 3.2` header is required on publish (single/bulk) and is
7+
publish-only — applying it to fetch/upload returns 404
8+
9+
Asset scanning itself behaves identically in both orgs (verified): the
10+
include_asset_scan_status=true param surfaces _asset_scan_status with values
11+
pending -> clean | quarantined. The whole file skips when AM_ORG_UID is unset.
12+
"""
13+
14+
import os
15+
16+
import pytest
17+
18+
from framework import helpers as h
19+
20+
pytestmark = pytest.mark.order(31)
21+
22+
_ASSET_PATH = os.path.join(os.path.dirname(__file__), "..", "data", "assets", "sample.png")
23+
24+
25+
class TestAMAssetBasics:
26+
def test_upload_has_am_uid_prefix(self, am_stack):
27+
resp = am_stack.assets().upload(_ASSET_PATH)
28+
h.assert_status(resp, 201)
29+
uid = h.body(resp).get("asset", {}).get("uid", "")
30+
h.tracked_assert(uid[:2], "AM 2.0 asset uid prefix").equals("am")
31+
32+
def test_crud_round_trip(self, am_stack):
33+
uid = h.body(am_stack.assets().upload(_ASSET_PATH)).get("asset", {}).get("uid")
34+
h.wait(h.SHORT_DELAY)
35+
h.assert_status(am_stack.assets(uid).fetch(), 200)
36+
h.assert_status(am_stack.assets().find(), 200)
37+
h.assert_status(am_stack.assets(uid).version(), 200)
38+
h.assert_status(am_stack.assets(uid).delete(), 200)
39+
40+
41+
class TestAMAssetScan:
42+
def test_upload_returns_pending(self, am_stack):
43+
asset = am_stack.assets()
44+
asset.add_param("include_asset_scan_status", "true")
45+
resp = asset.upload(_ASSET_PATH)
46+
h.assert_status(resp, 201)
47+
h.tracked_assert(
48+
h.body(resp).get("asset", {}).get("_asset_scan_status"), "scan status on upload"
49+
).equals("pending")
50+
51+
def test_clean_asset_scanned_clean(self, am_stack):
52+
uid = h.body(am_stack.assets().upload(_ASSET_PATH)).get("asset", {}).get("uid")
53+
status = h.wait_for_scan(am_stack, uid, "clean")
54+
h.tracked_assert(status, "clean file scan result").equals("clean")
55+
56+
def test_malware_asset_quarantined(self, am_stack, eicar_file):
57+
uid = h.body(am_stack.assets().upload(eicar_file)).get("asset", {}).get("uid")
58+
status = h.wait_for_scan(am_stack, uid, "quarantined")
59+
h.tracked_assert(status, "EICAR scan result").equals("quarantined")
60+
61+
def test_scan_status_absent_without_param(self, am_stack):
62+
uid = h.body(am_stack.assets().upload(_ASSET_PATH)).get("asset", {}).get("uid")
63+
asset = h.body(am_stack.assets(uid).fetch()).get("asset", {})
64+
h.tracked_assert("_asset_scan_status" not in asset, "field absent w/o param").equals(True)
65+
66+
67+
class TestAMAssetPublish:
68+
def test_publish_with_api_version_3_2(self, am_stack):
69+
uid = h.body(am_stack.assets().upload(_ASSET_PATH)).get("asset", {}).get("uid")
70+
# upload() pops Content-Type; restore it before the JSON requests below.
71+
am_stack.client.headers["Content-Type"] = "application/json"
72+
env = h.generate_valid_uid("env_am")
73+
am_stack.environments().create(
74+
{"environment": {"name": env, "urls": [{"url": "https://e.example.com", "locale": "en-us"}]}}
75+
)
76+
h.wait(h.SHORT_DELAY)
77+
am_stack.client.headers["Content-Type"] = "application/json"
78+
asset = am_stack.assets(uid)
79+
asset.add_header("api_version", "3.2")
80+
resp = asset.publish({"asset": {"locales": ["en-us"], "environments": [env]}, "version": 1})
81+
# Publish always returns success; scan validation happens async on the CDA side.
82+
h.assert_status(resp, 200, 201)
83+
84+
def test_api_version_3_2_is_publish_only(self, am_stack):
85+
# The api_version: 3.2 header is publish-only — on fetch it 404s.
86+
uid = h.body(am_stack.assets().upload(_ASSET_PATH)).get("asset", {}).get("uid")
87+
asset = am_stack.assets(uid)
88+
asset.add_header("api_version", "3.2")
89+
resp = asset.fetch()
90+
h.assert_status(resp, 404)

tests/integration/conftest.py

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@
2929
from framework import capture, report
3030
from framework import setup as setup_mod
3131
from framework.context import reset_store, test_data
32-
from framework.helpers import set_active_tracker
32+
from framework.helpers import set_active_tracker, short_id, wait
3333
from framework.report import TestRecord
3434

3535
load_dotenv()
@@ -72,6 +72,60 @@ def store():
7272
return test_data
7373

7474

75+
@pytest.fixture(scope="session")
76+
def am_stack(ctx):
77+
"""A stack created in the AM 2.0 (DAM-enabled) org from AM_ORG_UID.
78+
79+
Skips the whole AM suite when AM_ORG_UID is not configured. Reuses the
80+
authenticated session client; only the stack lives in the AM org.
81+
"""
82+
am_org = os.getenv("AM_ORG_UID")
83+
if not am_org:
84+
pytest.skip("AM_ORG_UID not set — AM 2.0 tests require a DAM-enabled org")
85+
client = ctx.client
86+
# This session fixture runs before the per-test header reset, so restore a
87+
# clean JSON Content-Type (a prior asset upload may have mutated/popped it).
88+
client.client.headers["Content-Type"] = "application/json"
89+
client.client.headers.pop("api_version", None)
90+
client.client.headers["organization_uid"] = am_org # Stack.create org-header workaround
91+
resp = client.stack().create(am_org, {"stack": {
92+
"name": f"SDK_Py_AM_{short_id()}",
93+
"description": "Automated AM 2.0 test stack",
94+
"master_locale": "en-us",
95+
}})
96+
if resp.status_code not in (200, 201):
97+
client.client.headers["organization_uid"] = ctx.organization_uid
98+
pytest.skip(f"could not create AM stack ({resp.status_code}): {resp.text[:120]}")
99+
api_key = resp.json()["stack"]["api_key"]
100+
wait(5)
101+
yield client.stack(api_key)
102+
# teardown: delete the AM stack, then restore the normal org header
103+
if setup_mod.should_delete_resources():
104+
try:
105+
client.stack(api_key).delete()
106+
except Exception: # noqa: BLE001
107+
pass
108+
client.client.headers["organization_uid"] = ctx.organization_uid
109+
110+
111+
@pytest.fixture(scope="session")
112+
def eicar_file(tmp_path_factory):
113+
"""Path to an EICAR antivirus test file, written at runtime (never committed).
114+
115+
The asset scanner quarantines this standard test signature, letting us assert
116+
the 'quarantined' scan status. The signature is stored base64-encoded (not as a
117+
raw literal) so the source file itself isn't flagged by antivirus / repo scanners.
118+
"""
119+
import base64
120+
121+
signature = base64.b64decode(
122+
"WDVPIVAlQEFQWzRcUFpYNTQoUF4pN0NDKTd9JEVJQ0FSLVNUQU5EQVJELUFOVElWSVJVUy1URVNULUZJTEUhJEgrSCo="
123+
)
124+
path = tmp_path_factory.mktemp("am_scan") / "eicar.com"
125+
path.write_bytes(signature)
126+
return str(path)
127+
128+
75129
# ---------------------------------------------------------------------------
76130
# Per-test capture wiring
77131
# ---------------------------------------------------------------------------
@@ -101,6 +155,7 @@ def _reset_client_headers(request):
101155
headers = context.client.client.headers
102156
headers["Content-Type"] = "application/json"
103157
headers.pop("branch", None)
158+
headers.pop("api_version", None) # AM 2.0 publish header leaks otherwise (breaks later calls)
104159
yield
105160

106161

tests/integration/framework/helpers.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -92,6 +92,24 @@ def body(response) -> dict:
9292
return {}
9393

9494

95+
def wait_for_scan(stack, asset_uid, expected, timeout=40, interval=3):
96+
"""Poll an asset's _asset_scan_status (AM 2.0) until it reaches `expected`.
97+
98+
Requires the include_asset_scan_status=true query param to surface the field.
99+
Returns the last observed status (the caller asserts == expected).
100+
"""
101+
deadline = time.time() + timeout
102+
last = None
103+
while time.time() < deadline:
104+
asset = stack.assets(asset_uid)
105+
asset.add_param("include_asset_scan_status", "true")
106+
last = body(asset.fetch()).get("asset", {}).get("_asset_scan_status")
107+
if last == expected:
108+
return last
109+
time.sleep(interval)
110+
return last
111+
112+
95113
# ---------------------------------------------------------------------------
96114
# Status / error assertions (Python SDK does NOT raise on HTTP errors)
97115
# ---------------------------------------------------------------------------

0 commit comments

Comments
 (0)