Skip to content

Commit e2f43f3

Browse files
feat: add scan lifecycle tests for clean + quarantined status using EICAR
Ports the pattern from the Python CMA SDK tests (test_06_asset.py, test_31_am_assets.py) to JavaScript. New helpers: - EICAR_BASE64: standard 68-byte EICAR signature stored base64-encoded so source file is never flagged by Talisman / repo antivirus scanners - createEicarFile(): decodes to a temp file at runtime, deleted in after() - waitForScan(stack, uid, expected, timeout=60s): polls fetch({include_asset_scan_status:true}) every 3s until status matches or times out; treats not_scanned as terminal (feature disabled on stack) New describe: 'Asset Scan Status – Scan Lifecycle (clean + quarantined)' - clean image → polls until 'clean' - EICAR file → polls until 'quarantined' - quarantined download → verifies SDK surfaces 403/422 with scan error code (§ 3.3 re-tested with a REAL quarantined asset, not a fake URL) AM org additions (AM-8, AM-9): - [AM] clean image transitions pending → clean - [AM] EICAR file reaches quarantined status Also adds: import fs from 'fs' and import os from 'os' Verified locally: 37 passing, 0 pending, 0 failing
1 parent be7b317 commit e2f43f3

2 files changed

Lines changed: 195 additions & 1 deletion

File tree

.talismanrc

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,5 +4,5 @@ fileignoreconfig:
44
- filename: test/sanity-check/sanity.js
55
checksum: f8b4b4b4492e04fd13338af9d99972807b4d61e2b0237a6c057d3f93d8c66d60
66
- filename: test/sanity-check/api/assetScanStatus-test.js
7-
checksum: c6e0485112f81ebf58ee4836a2dc1ed79e9c3395e9fa66968d0727ab4a9976db
7+
checksum: e3b1857fbe321e7125b55613acd58c3c0b2fd2462e7a14e48279ad35db4169e7
88
version: "1.0"

test/sanity-check/api/assetScanStatus-test.js

Lines changed: 194 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,8 @@ import { contentstackClient } from '../utility/ContentstackClient.js'
3535
import { testData, wait, trackedExpect } from '../utility/testHelpers.js'
3636
import * as testSetup from '../utility/testSetup.js'
3737
import path from 'path'
38+
import fs from 'fs'
39+
import os from 'os'
3840

3941
const testBaseDir = path.resolve(process.cwd(), 'test/sanity-check')
4042
const assetPath = path.join(testBaseDir, 'mock/assets/image-1.jpg')
@@ -78,6 +80,37 @@ async function uploadScanAsset (stack, label) {
7880
}
7981
}
8082

83+
// EICAR antivirus test signature — stored base64-encoded so the source file is
84+
// never flagged by repo scanners. Decodes to the standard 68-byte EICAR string
85+
// that all AV engines recognise as a test virus (harmless, triggers quarantine).
86+
const EICAR_BASE64 = 'WDVPIVAlQEFQWzRcUFpYNTQoUF4pN0NDKTd9JEVJQ0FSLVNUQU5EQVJELUFOVElWSVJVUy1URVNULUZJTEUhJEgrSCo='
87+
88+
function createEicarFile () {
89+
const tmpPath = path.join(os.tmpdir(), `eicar-scan-test-${Date.now()}.com`)
90+
fs.writeFileSync(tmpPath, Buffer.from(EICAR_BASE64, 'base64'))
91+
return tmpPath
92+
}
93+
94+
/**
95+
* Poll _asset_scan_status every `interval` ms until it equals `expectedStatus`
96+
* or `timeout` ms elapses. Returns the last observed status.
97+
* `not_scanned` is treated as terminal — the scanner will never change it.
98+
*/
99+
async function waitForScan (stack, assetUid, expectedStatus, timeout = 60000, interval = 3000) {
100+
const deadline = Date.now() + timeout
101+
let last = null
102+
while (Date.now() < deadline) {
103+
try {
104+
const asset = await stack.asset(assetUid).fetch({ include_asset_scan_status: true })
105+
last = asset._asset_scan_status
106+
if (last === expectedStatus) return last
107+
if (last === 'not_scanned') return last // feature disabled — won't change
108+
} catch (e) { /* transient network error — keep polling */ }
109+
await wait(interval)
110+
}
111+
return last
112+
}
113+
81114
// ============================================================================
82115
// Part 1 – Non-AM Org (ORGANIZATION, scan enabled)
83116
// ============================================================================
@@ -487,6 +520,114 @@ describe('Asset Scan Status – Download Error Handling (§ 3.3)', () => {
487520
})
488521
})
489522

523+
// ============================================================================
524+
// Scan Lifecycle – pending → clean / pending → quarantined
525+
// Verifies each distinct status value is reachable and correct.
526+
// Uses a real normal image (→ clean) and EICAR test file (→ quarantined).
527+
// ============================================================================
528+
529+
describe('Asset Scan Status – Scan Lifecycle (clean + quarantined)', () => {
530+
let stack
531+
let cleanAssetUid
532+
let eicarAssetUid
533+
let eicarFilePath
534+
535+
before(function () {
536+
const apiKey = process.env.API_KEY
537+
if (!apiKey) return this.skip()
538+
stack = buildStack(apiKey)
539+
})
540+
541+
before(async function () {
542+
this.timeout(30000)
543+
if (!stack) return
544+
545+
// Write EICAR test file to a system temp path
546+
try { eicarFilePath = createEicarFile() } catch (e) {
547+
console.log(' [scan-test] EICAR file creation failed:', e.message)
548+
}
549+
550+
// Upload both assets
551+
cleanAssetUid = await uploadScanAsset(stack, 'lifecycle-clean')
552+
if (eicarFilePath) {
553+
try {
554+
const a = await stack.asset().create({
555+
upload: eicarFilePath,
556+
title: `Scan Test EICAR ${Date.now()}`,
557+
description: 'EICAR antivirus test file for quarantine status verification'
558+
})
559+
eicarAssetUid = a.uid
560+
} catch (e) {
561+
console.log(' [scan-test] EICAR upload failed:', e.errorMessage || e.message)
562+
}
563+
}
564+
console.log(` [scan-test] Lifecycle: cleanUid=${cleanAssetUid} eicarUid=${eicarAssetUid}`)
565+
})
566+
567+
after(async function () {
568+
for (const uid of [cleanAssetUid, eicarAssetUid]) {
569+
if (uid && stack) try { await stack.asset(uid).delete() } catch (e) {}
570+
}
571+
if (eicarFilePath) try { fs.unlinkSync(eicarFilePath) } catch (e) {}
572+
})
573+
574+
it('clean image should transition from pending to clean after scan completes', async function () {
575+
this.timeout(90000)
576+
if (!cleanAssetUid) return this.skip()
577+
578+
const status = await waitForScan(stack, cleanAssetUid, 'clean')
579+
if (status === 'not_scanned') {
580+
console.log(' [scan-test] Scanning not enabled on this stack (not_scanned) — skipping clean assertion')
581+
return
582+
}
583+
expect(status).to.equal('clean',
584+
`Expected normal image to scan as 'clean', got: ${JSON.stringify(status)}`)
585+
})
586+
587+
it('EICAR antivirus test file should reach quarantined status after scan', async function () {
588+
this.timeout(90000)
589+
if (!eicarAssetUid) return this.skip()
590+
591+
const status = await waitForScan(stack, eicarAssetUid, 'quarantined')
592+
if (status === 'not_scanned') {
593+
console.log(' [scan-test] Scanning not enabled on this stack (not_scanned) — skipping quarantine assertion')
594+
return
595+
}
596+
expect(status).to.equal('quarantined',
597+
`Expected EICAR file to be quarantined, got: ${JSON.stringify(status)}`)
598+
})
599+
600+
it('quarantined asset download should be blocked with scan error (§ 3.3 real asset)', async function () {
601+
this.timeout(90000)
602+
if (!eicarAssetUid) return this.skip()
603+
604+
// Only run once the asset is confirmed quarantined
605+
const status = await waitForScan(stack, eicarAssetUid, 'quarantined')
606+
if (status !== 'quarantined') return this.skip()
607+
608+
try {
609+
const asset = await stack.asset(eicarAssetUid).fetch({ include_asset_scan_status: true })
610+
// Attempt to download — should be blocked for quarantined assets
611+
await stack.asset().download({ url: asset.url, responseType: 'arraybuffer' })
612+
} catch (err) {
613+
const httpStatus = err.status || (err.response && err.response.status)
614+
const errCode = String(
615+
err.errorCode || err.error_code ||
616+
(err.response && err.response.data && err.response.data.error_code) || ''
617+
)
618+
// § 3.3: blocked download must surface 403 or 422, not a generic/swallowed error
619+
if (httpStatus) {
620+
expect([403, 422]).to.include(httpStatus,
621+
`Quarantined asset download must return 403 or 422, got: ${httpStatus}`)
622+
}
623+
if (errCode) {
624+
expect(['asset_scan_quarantined', 'access_denied']).to.include(errCode,
625+
`Expected scan-related error code, got: ${errCode}`)
626+
}
627+
}
628+
})
629+
})
630+
490631
// ============================================================================
491632
// § 3.4 – Single asset publish: scan validation is ASYNC, not blocking
492633
// Design doc: "The publish API always returns 'Asset sent for publishing' —
@@ -978,4 +1119,57 @@ describe('Asset Scan Status – AM Org (AM_ORG_UID, DAM + scan enabled)', functi
9781119
expect(VALID_SCAN_STATUSES).to.include(asset._asset_scan_status)
9791120
}
9801121
})
1122+
1123+
// --------------------------------------------------------------------------
1124+
// AM-8. Clean image transitions pending → clean on AM (DAM) stack
1125+
// --------------------------------------------------------------------------
1126+
it('[AM] clean image should transition from pending to clean after scan', async function () {
1127+
this.timeout(90000)
1128+
if (!amFreshAssetUid) return this.skip()
1129+
1130+
const status = await waitForScan(amStack, amFreshAssetUid, 'clean')
1131+
if (status === 'not_scanned') {
1132+
console.log(' [scan-test] AM: scanning not enabled on this stack — skipping clean assertion')
1133+
return
1134+
}
1135+
expect(status).to.equal('clean',
1136+
`AM clean image expected 'clean' after scan, got: ${JSON.stringify(status)}`)
1137+
})
1138+
1139+
// --------------------------------------------------------------------------
1140+
// AM-9. EICAR file reaches quarantined on AM (DAM) stack
1141+
// --------------------------------------------------------------------------
1142+
it('[AM] EICAR antivirus test file should reach quarantined status on AM stack', async function () {
1143+
this.timeout(90000)
1144+
if (!amStack) return this.skip()
1145+
1146+
let eicarPath
1147+
let eicarUid
1148+
try {
1149+
eicarPath = createEicarFile()
1150+
const a = await amStack.asset().create({
1151+
upload: eicarPath,
1152+
title: `AM EICAR Test ${Date.now()}`,
1153+
description: 'EICAR antivirus test file for quarantine verification on AM stack'
1154+
})
1155+
eicarUid = a.uid
1156+
} catch (e) {
1157+
console.log(' [scan-test] AM EICAR upload failed:', e.errorMessage || e.message)
1158+
return this.skip()
1159+
} finally {
1160+
if (eicarPath) try { fs.unlinkSync(eicarPath) } catch (e) {}
1161+
}
1162+
1163+
try {
1164+
const status = await waitForScan(amStack, eicarUid, 'quarantined')
1165+
if (status === 'not_scanned') {
1166+
console.log(' [scan-test] AM: scanning not enabled — skipping quarantine assertion')
1167+
return
1168+
}
1169+
expect(status).to.equal('quarantined',
1170+
`AM EICAR file expected 'quarantined', got: ${JSON.stringify(status)}`)
1171+
} finally {
1172+
if (eicarUid) try { await amStack.asset(eicarUid).delete() } catch (e) {}
1173+
}
1174+
})
9811175
})

0 commit comments

Comments
 (0)