From 8e6a95221d7de8a43ea6b39c676f0033dda77bd5 Mon Sep 17 00:00:00 2001 From: Piotr Galar Date: Sat, 29 Aug 2026 19:02:31 +0100 Subject: [PATCH 1/2] test: add optional devnet scenarios --- .github/workflows/ci_pull_request.yml | 2 + .github/workflows/ci_run.yml | 14 ++- README_ADVANCED.md | 11 +- scenarios/report.py | 30 ++++- scenarios/run.py | 68 ++++++++--- scenarios/synapse-e2e/bulk-add.ts | 113 ++++++++++++++++++ scenarios/synapse-e2e/negative-permissions.ts | 84 +++++++++++++ scenarios/synapse-e2e/termination-controls.ts | 70 +++++++++++ scenarios/test_bulk_add.py | 37 ++++++ scenarios/test_negative_permissions.py | 39 ++++++ scenarios/test_termination_controls.py | 37 ++++++ scripts/tests/test_scenario_runner.py | 57 +++++++++ 12 files changed, 541 insertions(+), 21 deletions(-) create mode 100644 scenarios/synapse-e2e/bulk-add.ts create mode 100644 scenarios/synapse-e2e/negative-permissions.ts create mode 100644 scenarios/synapse-e2e/termination-controls.ts create mode 100644 scenarios/test_bulk_add.py create mode 100644 scenarios/test_negative_permissions.py create mode 100644 scenarios/test_termination_controls.py create mode 100644 scripts/tests/test_scenario_runner.py diff --git a/.github/workflows/ci_pull_request.yml b/.github/workflows/ci_pull_request.yml index 02d4723a..aae405f5 100644 --- a/.github/workflows/ci_pull_request.yml +++ b/.github/workflows/ci_pull_request.yml @@ -9,6 +9,7 @@ name: CI (Pull Request) on: pull_request: + types: [opened, reopened, synchronize, labeled, unlabeled] push: branches: ['main'] @@ -59,5 +60,6 @@ jobs: with: name: default profile: default + include_optional_scenarios: ${{ github.event_name == 'pull_request' && contains(github.event.pull_request.labels.*.name, 'run-optional-scenarios') }} enable_reporting: false secrets: inherit diff --git a/.github/workflows/ci_run.yml b/.github/workflows/ci_run.yml index 54b66960..25ef0ec2 100644 --- a/.github/workflows/ci_run.yml +++ b/.github/workflows/ci_run.yml @@ -20,6 +20,11 @@ on: description: 'Dependency profile declared in ci/dependency-profiles.json' required: true type: string + include_optional_scenarios: + description: 'Run optional extended scenarios after the core suite' + required: false + type: boolean + default: false enable_reporting: description: 'When true, file a GitHub issue with the scenario report' required: false @@ -44,7 +49,7 @@ on: jobs: foc-start-test: runs-on: ["self-hosted", "linux", "x64", "4xlarge+disk"] - timeout-minutes: 100 + timeout-minutes: 150 permissions: contents: read issues: write @@ -423,7 +428,12 @@ jobs: SKIP_REPORT_ON_PASS: ${{ inputs.skip_report_on_pass }} GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} SCENARIO_RUN_TYPE: ${{ inputs.name }} - run: python3 scenarios/run.py + run: | + if [[ "${{ inputs.include_optional_scenarios }}" == "true" ]]; then + python3 scenarios/run.py --include-optional + else + python3 scenarios/run.py + fi # Ensure scenario report exists even if tests didn't run (for issue reporting) - name: "EXEC: {Ensure scenario report exists}" diff --git a/README_ADVANCED.md b/README_ADVANCED.md index b02c5f2b..f5b1b8d2 100644 --- a/README_ADVANCED.md +++ b/README_ADVANCED.md @@ -1314,9 +1314,12 @@ Scenario tests are Python scripts that validate devnet state after startup. They ### Running scenarios ```bash -# Run all scenarios +# Run the core scenario suite python3 scenarios/run.py +# Include extended optional scenarios +python3 scenarios/run.py --include-optional + # Run a single scenario directly python3 scenarios/test_basic_balances.py @@ -1328,7 +1331,11 @@ Reports are written to `~/.foc-devnet/state/latest/scenario_report.md`. ### CI integration -Scenarios run automatically in CI after the devnet starts. On nightly runs (or manual dispatch with `reporting` enabled), failures automatically create a GitHub issue with a full report. +Scenarios run automatically in CI after the devnet starts. Pull requests run the +core suite by default. Apply the `run-optional-scenarios` label to rerun the +same pull-request CI with the extended scenarios included. On nightly runs (or +manual dispatch with `reporting` enabled), failures automatically create a +GitHub issue with a full report. CI resolves compatibility-sensitive dependencies from `ci/dependency-profiles.json`. Pull requests use the pinned `default` profile, while nightly `stability` runs use diff --git a/scenarios/report.py b/scenarios/report.py index facab24d..9b573a2f 100644 --- a/scenarios/report.py +++ b/scenarios/report.py @@ -43,7 +43,8 @@ def get_version_info(): return "foc-devnet version: not available" -_REPORT_TEMPLATE = Template(""" +_REPORT_TEMPLATE = Template( + """ # Scenarios Tests | Description | Data | @@ -59,9 +60,15 @@ def get_version_info(): ## Resolved dependencies $dependency_table +## Scenario selection +**$scenario_selection** + +$skipped_optional_tests + ## Tests summary $test_summary -""") +""" +) def _build_ci_run_link(): @@ -93,10 +100,25 @@ def _build_test_summary(results: list[TestResult]) -> str: return "\n\n".join(parts) -def write_report(results: list[TestResult] | None = None, elapsed: int = 0): +def _format_skipped_optional_tests(skipped_tests: list[str]) -> str: + if not skipped_tests: + return "No optional scenarios skipped." + return "Skipped optional scenarios:\n" + "\n".join( + f"- `{test_name}`" for test_name in skipped_tests + ) + + +def write_report( + results: list[TestResult] | None = None, + elapsed: int = 0, + selection: str = "core", + skipped_tests: list[str] | None = None, +): """Write a markdown report to REPORT_MD. Returns path written.""" if results is None: results = [] + if skipped_tests is None: + skipped_tests = [] total = len(results) passed = sum(1 for r in results if r.is_passed) content = _REPORT_TEMPLATE.substitute( @@ -108,6 +130,8 @@ def write_report(results: list[TestResult] | None = None, elapsed: int = 0): ci_run_link=_build_ci_run_link(), version_info=f"```\n{get_version_info()}\n```", dependency_table=format_markdown_table(), + scenario_selection=selection, + skipped_optional_tests=_format_skipped_optional_tests(skipped_tests), test_summary=_build_test_summary(results), ) with open(REPORT_MD, "w") as fh: diff --git a/scenarios/run.py b/scenarios/run.py index a89ce0cd..65c8a241 100755 --- a/scenarios/run.py +++ b/scenarios/run.py @@ -1,10 +1,12 @@ #!/usr/bin/env python3 """Scenario test runner — executes tests in order and generates a report. -Run all tests: python3 scenarios/run.py -Run one test: python3 scenarios/test_containers.py +Run core tests: python3 scenarios/run.py +Run core + optional: python3 scenarios/run.py --include-optional +Run one test: python3 scenarios/test_containers.py """ +import argparse import os import subprocess import sys @@ -22,20 +24,36 @@ from scenarios.report import TestResult, write_report # ── Scenario execution order ───────────────────────────────── -# Each entry is (test_name, timeout_seconds) +# Each entry is (test_name, timeout_seconds, optional). Optional scenarios are +# discoverable and runnable directly, but omitted from the default core run. CREATE_DATASET_SMOKE_TIMEOUT_SECS = 1800 ORDER = [ - ("test_containers", 5), - ("test_basic_balances", 10), + ("test_containers", 5, False), + ("test_basic_balances", 10, False), # Allows setup plus five 280s Node attempts and retry delays. - ("test_create_dataset_smoke", CREATE_DATASET_SMOKE_TIMEOUT_SECS), - ("test_synapse_e2e", 600), - ("test_multi_copy_upload", 600), - ("test_caching_subsystem", 200), + ("test_create_dataset_smoke", CREATE_DATASET_SMOKE_TIMEOUT_SECS, False), + ("test_synapse_e2e", 600, False), + ("test_negative_permissions", 300, False), + ("test_multi_copy_upload", 600, False), + ("test_caching_subsystem", 200, False), + ("test_bulk_add", 1800, True), + ("test_termination_controls", 900, True), ] +def select_scenarios(include_optional, order=ORDER): + """Return (selected, skipped) entries for an ordered scenario collection.""" + selected = [] + skipped = [] + for scenario in order: + if scenario[2] and not include_optional: + skipped.append(scenario) + else: + selected.append(scenario) + return selected, skipped + + def _run_single_test(scenario_py_file, name, timeout_sec): """Run one scenario file as a subprocess, return a TestResult.""" info(f"=== {name} (timeout: {timeout_sec}s) ===") @@ -75,15 +93,26 @@ def _run_single_test(scenario_py_file, name, timeout_sec): ) -def run_tests(): - """Run scenarios in ORDER. Returns list of TestResult.""" +def run_tests(include_optional=False): + """Run selected scenarios in ORDER. Returns list of TestResult.""" pwd = os.path.dirname(os.path.abspath(__file__)) + selected, _ = select_scenarios(include_optional) return [ _run_single_test(os.path.join(pwd, f"{name}.py"), name, timeout) - for name, timeout in ORDER + for name, timeout, _ in selected ] +def _parse_args(): + parser = argparse.ArgumentParser(description="Run foc-devnet scenario tests") + parser.add_argument( + "--include-optional", + action="store_true", + help="run optional extended scenarios after the core scenarios", + ) + return parser.parse_args() + + def _print_summary(results, elapsed): """Print a human-readable summary to stdout.""" passed = sum(1 for r in results if r.is_passed) @@ -116,10 +145,21 @@ def _print_ci_url(): if __name__ == "__main__": + args = _parse_args() + _, skipped = select_scenarios(args.include_optional) + selection = "core + optional" if args.include_optional else "core" + info(f"Scenario selection: {selection}") + if skipped: + info( + "Skipping optional scenarios (use --include-optional): " + + ", ".join(name for name, _, _ in skipped) + ) start = time.time() - results = run_tests() + results = run_tests(args.include_optional) elapsed = int(time.time() - start) _print_summary(results, elapsed) - print(f"Report: {write_report(results=results)}") + print( + f"Report: {write_report(results=results, selection=selection, skipped_tests=[name for name, _, _ in skipped])}" + ) _print_ci_url() sys.exit(0 if all(r.is_passed for r in results) else 1) diff --git a/scenarios/synapse-e2e/bulk-add.ts b/scenarios/synapse-e2e/bulk-add.ts new file mode 100644 index 00000000..033542bb --- /dev/null +++ b/scenarios/synapse-e2e/bulk-add.ts @@ -0,0 +1,113 @@ +import assert from 'node:assert/strict' +import * as SP from '@filoz/synapse-core/sp' +import { findPieceIdsByCidCall, getActivePieceCount } from '@filoz/synapse-core/pdp-verifier' +import { getPdpDataSet } from '@filoz/synapse-core/warm-storage' +import { readContract } from 'viem/actions' +import { createSynapse, prepareAccount, readAccountState } from './account.ts' +import { freshMetadata, resolveEnvironment } from './environment.ts' +import { fileSize, uploadFile } from './storage.ts' + +const REQUIRED_PIECES = 40 +const MAX_PIECES = 80 +const SMALL_PIECE_BYTES = 64 * 1024 + +type PaymentSnapshot = { + pieceNumber: number + funds: bigint + availableFunds: bigint + lockupCurrent: bigint + lockupRate: bigint +} + +function fixtureFor(pieceNumber: number): File { + const bytes = Buffer.alloc(SMALL_PIECE_BYTES, pieceNumber % 251) + bytes.write(`foc-devnet-bulk-add-${pieceNumber}`, 0, 'utf8') + return new File([bytes], `bulk-${pieceNumber.toString().padStart(3, '0')}.bin`, { + type: 'application/octet-stream', + }) +} + +async function main(): Promise { + const environment = resolveEnvironment({ defaultUserIndex: 0, requireFiles: true }) + if (environment.filePaths.length !== 1) throw new Error('bulk-add.ts accepts exactly one bootstrap file path') + const [bootstrapPath] = environment.filePaths + const synapse = createSynapse(environment) + + // Prepare enough headroom for creation plus the mandatory 40 small additions. + await prepareAccount(synapse, (await fileSize(bootstrapPath)) + BigInt(SMALL_PIECE_BYTES * MAX_PIECES)) + const { result } = await uploadFile(synapse, bootstrapPath, freshMetadata('bulk-add'), 1) + const copy = result.copies[0] + assert(copy != null, 'Expected a bootstrap data set') + const dataSet = await getPdpDataSet(synapse.client, { dataSetId: copy.dataSetId }) + assert(dataSet != null && dataSet.live, `Bootstrap data set ${copy.dataSetId} is not live`) + const initialPieceCount = await getActivePieceCount(synapse.client, { dataSetId: copy.dataSetId }) + + const snapshots: PaymentSnapshot[] = [] + const pieceCids = [] + let replenishedAt: number | undefined + let previous = await readAccountState(synapse) + + for (let pieceNumber = 1; pieceNumber <= MAX_PIECES; pieceNumber++) { + const added = await SP.upload(synapse.client, { + dataSetId: copy.dataSetId, + data: [fixtureFor(pieceNumber)], + }) + assert.equal(added.pieces.length, 1, `Expected exactly one submitted piece at add ${pieceNumber}`) + const confirmed = await SP.waitForAddPieces({ statusUrl: added.statusUrl, timeout: 180_000 }) + assert.equal(confirmed.piecesAdded, true, `Piece ${pieceNumber} was not added`) + assert.equal(confirmed.confirmedPieceIds.length, 1, `Piece ${pieceNumber} confirmation was incomplete`) + + const current = await readAccountState(synapse) + snapshots.push({ + pieceNumber, + funds: current.funds, + availableFunds: current.availableFunds, + lockupCurrent: current.lockupCurrent, + lockupRate: current.lockupRate, + }) + pieceCids.push(added.pieces[0].pieceCid) + console.log( + `Added ${pieceNumber}: piece=${added.pieces[0].pieceCid} funds=${current.funds} ` + + `available=${current.availableFunds} lockup=${current.lockupCurrent} rate=${current.lockupRate}` + ) + + if (pieceNumber >= REQUIRED_PIECES && current.lockupCurrent > previous.lockupCurrent) { + replenishedAt = pieceNumber + break + } + previous = current + } + + assert.equal(snapshots.length >= REQUIRED_PIECES, true, `Expected at least ${REQUIRED_PIECES} additions`) + assert(replenishedAt != null, `Lockup did not replenish within ${MAX_PIECES} additions`) + console.log(`Lockup replenished after piece ${replenishedAt}`) + + const activePieceCount = await getActivePieceCount(synapse.client, { dataSetId: copy.dataSetId }) + assert.equal( + activePieceCount, + initialPieceCount + BigInt(snapshots.length), + 'On-chain active-piece count differs from successfully submitted pieces' + ) + for (const pieceCid of pieceCids) { + const ids = await readContract( + synapse.client, + findPieceIdsByCidCall({ + chain: synapse.client.chain, + dataSetId: copy.dataSetId, + pieceCid, + startPieceId: 0n, + limit: 2n, + }) + ) + assert.equal(ids.length, 1, `Submitted piece ${pieceCid} is not discoverable on-chain`) + } + console.log( + `=== SUCCESS: ${snapshots.length} distinct pieces are discoverable on data set ${copy.dataSetId}; ` + + `lockup replenished at ${replenishedAt} ===` + ) +} + +main().catch((error: unknown) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/scenarios/synapse-e2e/negative-permissions.ts b/scenarios/synapse-e2e/negative-permissions.ts new file mode 100644 index 00000000..6c32a2db --- /dev/null +++ b/scenarios/synapse-e2e/negative-permissions.ts @@ -0,0 +1,84 @@ +import assert from 'node:assert/strict' +import * as SP from '@filoz/synapse-core/sp' +import { getRail } from '@filoz/synapse-core/pay' +import { getActivePieceCount } from '@filoz/synapse-core/pdp-verifier' +import { getPdpDataSet } from '@filoz/synapse-core/warm-storage' +import { createSynapse, prepareAccount, readAccountState } from './account.ts' +import { freshMetadata, resolveEnvironment } from './environment.ts' +import { fileSize, uploadFile } from './storage.ts' + +type DataSetSnapshot = { + live: boolean + activePieceCount: bigint + rail: { endEpoch: bigint; paymentRate: bigint; lockupPeriod: bigint } + payment: { funds: bigint; availableFunds: bigint; lockupCurrent: bigint; lockupRate: bigint } +} + +async function snapshot(synapse: ReturnType, dataSetId: bigint): Promise { + const dataSet = await getPdpDataSet(synapse.client, { dataSetId }) + assert(dataSet != null, `Data set ${dataSetId} is not readable`) + const [activePieceCount, rail, payment] = await Promise.all([ + getActivePieceCount(synapse.client, { dataSetId }), + getRail(synapse.client, { railId: dataSet.pdpRailId }), + readAccountState(synapse), + ]) + return { + live: dataSet.live, + activePieceCount, + rail: { endEpoch: rail.endEpoch, paymentRate: rail.paymentRate, lockupPeriod: rail.lockupPeriod }, + payment: { + funds: payment.funds, + availableFunds: payment.availableFunds, + lockupCurrent: payment.lockupCurrent, + lockupRate: payment.lockupRate, + }, + } +} + +async function main(): Promise { + const ownerEnvironment = resolveEnvironment({ defaultUserIndex: 0, requireFiles: true }) + if (ownerEnvironment.filePaths.length !== 1) throw new Error('negative-permissions.ts accepts exactly one file path') + const owner = createSynapse(ownerEnvironment) + const intruder = createSynapse(resolveEnvironment({ defaultUserIndex: 1 })) + const [filePath] = ownerEnvironment.filePaths + + await prepareAccount(owner, await fileSize(filePath)) + const { result } = await uploadFile(owner, filePath, freshMetadata('negative-permissions'), 1) + const copy = result.copies[0] + assert(copy != null, 'Expected one live data set for permission checks') + const dataSet = await getPdpDataSet(owner.client, { dataSetId: copy.dataSetId }) + assert(dataSet != null && dataSet.live, `Data set ${copy.dataSetId} was not created live`) + const serviceURL = dataSet.provider.pdp.serviceURL + const before = await snapshot(owner, copy.dataSetId) + + await assert.rejects( + SP.terminateService(intruder.client, { serviceURL, dataSetId: copy.dataSetId }), + 'a non-owner must not be able to relay termination' + ) + await assert.rejects( + SP.terminateServiceApiRequest({ serviceURL, dataSetId: copy.dataSetId, extraData: '0x' }), + 'malformed relayed termination data must be rejected' + ) + await assert.rejects( + SP.schedulePieceDeletion(intruder.client, { + serviceURL, + dataSetId: copy.dataSetId, + clientDataSetId: dataSet.clientDataSetId, + pieceId: copy.pieceId, + }), + 'a non-owner must not be able to schedule removal' + ) + await assert.rejects( + SP.deletePiece({ serviceURL, dataSetId: copy.dataSetId, pieceId: copy.pieceId, extraData: '0x' }), + 'malformed removal data must be rejected' + ) + + const after = await snapshot(owner, copy.dataSetId) + assert.deepEqual(after, before, 'rejected requests must not mutate the data set, pieces, rail, or payment account') + console.log(`=== SUCCESS: rejected requests left data set ${copy.dataSetId} unchanged ===`) +} + +main().catch((error: unknown) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/scenarios/synapse-e2e/termination-controls.ts b/scenarios/synapse-e2e/termination-controls.ts new file mode 100644 index 00000000..e6323dd3 --- /dev/null +++ b/scenarios/synapse-e2e/termination-controls.ts @@ -0,0 +1,70 @@ +import assert from 'node:assert/strict' +import { getRail } from '@filoz/synapse-core/pay' +import { getPriceList, getPdpDataSet } from '@filoz/synapse-core/warm-storage' +import { createSynapse, prepareAccount, readAccountState } from './account.ts' +import { freshMetadata, resolveEnvironment } from './environment.ts' +import { fileSize, uploadFile } from './storage.ts' + +const delay = (milliseconds: number) => new Promise((resolve) => setTimeout(resolve, milliseconds)) + +async function assertTerminated(synapse: ReturnType, dataSetId: bigint): Promise { + for (let attempt = 0; attempt < 30; attempt++) { + const dataSet = await getPdpDataSet(synapse.client, { dataSetId }) + if (dataSet != null && !dataSet.live) { + const rail = await getRail(synapse.client, { railId: dataSet.pdpRailId }) + assert(rail.endEpoch > 0n, `Terminated data set ${dataSetId} has an open payment rail`) + return + } + await delay(1000) + } + throw new Error(`Data set ${dataSetId} did not become terminated`) +} + +async function main(): Promise { + const environment = resolveEnvironment({ defaultUserIndex: 0, requireFiles: true }) + if (environment.filePaths.length !== 1) throw new Error('termination-controls.ts accepts exactly one file path') + const [filePath] = environment.filePaths + const synapse = createSynapse(environment) + await prepareAccount(synapse, (await fileSize(filePath)) * 2n) + + const { result } = await uploadFile(synapse, filePath, freshMetadata('termination-controls'), 2) + assert.equal(result.copies.length, 2, 'Expected two independent data sets') + const [relayedCopy, directCopy] = result.copies + const priceList = await getPriceList(synapse.client) + + const beforeRelayed = await readAccountState(synapse) + const relayed = await synapse.storage.terminateService({ + dataSetId: relayedCopy.dataSetId, + onSubmitted: (hash) => console.log(`SP-relayed termination submitted: ${hash}`), + }) + assert.equal(relayed.dataSetId, relayedCopy.dataSetId, 'Relayed termination returned the wrong data set') + await assertTerminated(synapse, relayedCopy.dataSetId) + const afterRelayed = await readAccountState(synapse) + assert.equal( + beforeRelayed.funds - afterRelayed.funds, + priceList.fees.terminateFee, + 'SP-relayed termination did not charge the configured termination fee' + ) + + const beforeDirect = await readAccountState(synapse) + const direct = await synapse.storage.terminateService({ + dataSetId: directCopy.dataSetId, + skipProvider: true, + onSubmitted: (hash) => console.log(`Direct termination submitted: ${hash}`), + }) + assert.equal(direct.dataSetId, directCopy.dataSetId, 'Direct termination returned the wrong data set') + assert(direct.txHash != null, 'Direct termination did not submit an on-chain transaction') + await assertTerminated(synapse, directCopy.dataSetId) + const afterDirect = await readAccountState(synapse) + assert.equal( + beforeDirect.funds - afterDirect.funds, + 0n, + 'Direct termination charged the SP-mediated termination fee' + ) + console.log('=== SUCCESS: relayed and direct termination controls observed; no cleanup wait performed ===') +} + +main().catch((error: unknown) => { + console.error(error) + process.exitCode = 1 +}) diff --git a/scenarios/test_bulk_add.py b/scenarios/test_bulk_add.py new file mode 100644 index 00000000..6268f75c --- /dev/null +++ b/scenarios/test_bulk_add.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Exercise the optional many-piece and lockup-replenishment path.""" + +import os +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from scenarios.helpers import assert_eq, assert_ok, info, write_random_file +from scenarios.synapse_runtime import prepare_synapse_runtime, run_node_script + +BOOTSTRAP_SIZE = 64 * 1024 + + +def run(): + assert_ok("command -v node", "node is installed") + with tempfile.TemporaryDirectory(prefix="bulk-add-") as tmp: + runtime = prepare_synapse_runtime(Path(tmp)) + fixture = runtime.work_dir / "bulk-bootstrap.bin" + write_random_file(fixture, BOOTSTRAP_SIZE, seed=12740) + assert_eq( + fixture.stat().st_size, BOOTSTRAP_SIZE, "bulk-add bootstrap fixture created" + ) + info("Running optional 40-piece add and lockup-replenishment checks") + run_node_script( + runtime, + "bulk-add.ts", + "bulk add scenario", + args=[str(fixture)], + env={"NETWORK": "devnet"}, + ) + + +if __name__ == "__main__": + run() diff --git a/scenarios/test_negative_permissions.py b/scenarios/test_negative_permissions.py new file mode 100644 index 00000000..0aac94d6 --- /dev/null +++ b/scenarios/test_negative_permissions.py @@ -0,0 +1,39 @@ +#!/usr/bin/env python3 +"""Verify rejected service-provider requests cannot mutate a live data set.""" + +import os +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from scenarios.helpers import assert_eq, assert_ok, info, write_random_file +from scenarios.synapse_runtime import prepare_synapse_runtime, run_node_script + +FIXTURE_SIZE = 1024 * 1024 + + +def run(): + assert_ok("command -v node", "node is installed") + with tempfile.TemporaryDirectory(prefix="negative-permissions-") as tmp: + runtime = prepare_synapse_runtime(Path(tmp)) + fixture = runtime.work_dir / "negative-permissions.bin" + write_random_file(fixture, FIXTURE_SIZE, seed=127) + assert_eq( + fixture.stat().st_size, FIXTURE_SIZE, "negative permissions fixture created" + ) + info( + "Running permission and malformed-request checks against an isolated data set" + ) + run_node_script( + runtime, + "negative-permissions.ts", + "negative permissions scenario", + args=[str(fixture)], + env={"NETWORK": "devnet"}, + ) + + +if __name__ == "__main__": + run() diff --git a/scenarios/test_termination_controls.py b/scenarios/test_termination_controls.py new file mode 100644 index 00000000..d97826a4 --- /dev/null +++ b/scenarios/test_termination_controls.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +"""Exercise optional relayed and direct data-set termination controls.""" + +import os +import sys +import tempfile +from pathlib import Path + +sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) + +from scenarios.helpers import assert_eq, assert_ok, info, write_random_file +from scenarios.synapse_runtime import prepare_synapse_runtime, run_node_script + +FIXTURE_SIZE = 1024 * 1024 + + +def run(): + assert_ok("command -v node", "node is installed") + with tempfile.TemporaryDirectory(prefix="termination-controls-") as tmp: + runtime = prepare_synapse_runtime(Path(tmp)) + fixture = runtime.work_dir / "termination-controls.bin" + write_random_file(fixture, FIXTURE_SIZE, seed=127900) + assert_eq( + fixture.stat().st_size, FIXTURE_SIZE, "termination controls fixture created" + ) + info("Running optional relayed and direct termination checks") + run_node_script( + runtime, + "termination-controls.ts", + "termination controls scenario", + args=[str(fixture)], + env={"NETWORK": "devnet"}, + ) + + +if __name__ == "__main__": + run() diff --git a/scripts/tests/test_scenario_runner.py b/scripts/tests/test_scenario_runner.py new file mode 100644 index 00000000..fca64f7c --- /dev/null +++ b/scripts/tests/test_scenario_runner.py @@ -0,0 +1,57 @@ +"""Unit tests for scenario selection without starting a devnet.""" + +import sys +import unittest +from unittest.mock import patch + +from scenarios.report import _format_skipped_optional_tests +from scenarios.run import ORDER, _parse_args, select_scenarios + + +class ScenarioSelectionTests(unittest.TestCase): + def test_core_selection_excludes_optional_scenarios(self): + selected, skipped = select_scenarios(False) + + self.assertEqual( + [entry[0] for entry in skipped], + ["test_bulk_add", "test_termination_controls"], + ) + self.assertEqual( + [entry[0] for entry in selected], [entry[0] for entry in ORDER[:-2]] + ) + self.assertTrue(all(not entry[2] for entry in selected)) + + def test_optional_selection_runs_everything_in_order(self): + selected, skipped = select_scenarios(True) + + self.assertEqual(selected, ORDER) + self.assertEqual(skipped, []) + + def test_include_optional_cli_flag_selects_every_scenario(self): + with patch.object(sys, "argv", ["run.py", "--include-optional"]): + args = _parse_args() + + selected, skipped = select_scenarios(args.include_optional) + self.assertTrue(args.include_optional) + self.assertEqual(selected, ORDER) + self.assertEqual(skipped, []) + + def test_selection_preserves_custom_order_and_report_data(self): + order = [("core", 1, False), ("extended", 2, True)] + + selected, skipped = select_scenarios(False, order) + + self.assertEqual(selected, [("core", 1, False)]) + self.assertEqual(skipped, [("extended", 2, True)]) + + def test_skipped_scenarios_are_rendered_for_the_report(self): + _, skipped = select_scenarios(False) + + rendered = _format_skipped_optional_tests([entry[0] for entry in skipped]) + + self.assertIn("`test_bulk_add`", rendered) + self.assertIn("`test_termination_controls`", rendered) + + +if __name__ == "__main__": + unittest.main() From c3b8b06d9a942cf48e1a3e221a419547fc5d8026 Mon Sep 17 00:00:00 2001 From: Piotr Galar Date: Sun, 30 Aug 2026 12:33:23 +0100 Subject: [PATCH 2/2] style: format scenario report with CI Black --- scenarios/report.py | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/scenarios/report.py b/scenarios/report.py index 9b573a2f..9e726a96 100644 --- a/scenarios/report.py +++ b/scenarios/report.py @@ -43,8 +43,7 @@ def get_version_info(): return "foc-devnet version: not available" -_REPORT_TEMPLATE = Template( - """ +_REPORT_TEMPLATE = Template(""" # Scenarios Tests | Description | Data | @@ -67,8 +66,7 @@ def get_version_info(): ## Tests summary $test_summary -""" -) +""") def _build_ci_run_link():