From 4d044b2618eec44ec5223b9eb02e0c919626f6f9 Mon Sep 17 00:00:00 2001 From: Mark Krapivner Date: Fri, 27 Mar 2026 11:54:16 +0300 Subject: [PATCH 01/25] Add comprehensive tests for script execution engine, sandbox security, and script service - Implement unit tests for the script execution engine covering context building, request mutations, variable changes, and sensitive value masking. - Introduce security tests for the Python and JavaScript sandboxes to ensure isolation and prevent escape vectors. - Add tests for the ScriptService to verify script chain resolution from the database and inline events. --- .github/copilot-instructions.md | 19 +- .../instructions/architecture.instructions.md | 13 + .github/instructions/testing.instructions.md | 6 + .../service-repository-reference/SKILL.md | 70 ++ .github/skills/signal-flow/SKILL.md | 25 +- data/scripts/pm_bootstrap.js | 701 ++++++++++++++++++ docs/README.md | 14 + docs/api-reference/services/script-engine.md | 183 +++++ docs/api-reference/services/script-service.md | 53 ++ docs/api-reference/typedicts.md | 165 ++--- docs/architecture/data-flow.md | 26 + docs/architecture/database-layer.md | 2 +- docs/architecture/service-layer.md | 2 + docs/guides/writing-scripts.md | 126 ++++ docs/scripting/collection-runner.md | 120 +++ docs/scripting/examples.md | 268 +++++++ docs/scripting/javascript-api.md | 287 +++++++ docs/scripting/overview.md | 119 +++ docs/scripting/python-api.md | 269 +++++++ docs/scripting/security.md | 151 ++++ docs/ui-reference/dialogs.md | 24 + docs/ui-reference/response-viewer.md | 27 + pyproject.toml | 2 + .../collection_query_repository.py | 48 ++ src/services/__init__.py | 16 + src/services/http/http_service.py | 5 + src/services/import_parser/models.py | 1 + src/services/import_service.py | 27 + src/services/script_service.py | 107 +++ src/services/scripting/__init__.py | 73 ++ src/services/scripting/_py_sandbox.py | 612 +++++++++++++++ src/services/scripting/context.py | 315 ++++++++ src/services/scripting/engine.py | 135 ++++ src/services/scripting/js_runtime.py | 268 +++++++ src/services/scripting/py_runtime.py | 194 +++++ src/ui/dialogs/collection_runner.py | 328 +++++++- src/ui/dialogs/import_dialog.py | 16 +- src/ui/dialogs/settings_dialog.py | 46 +- src/ui/main_window/send_pipeline.py | 56 +- src/ui/panels/console_panel.py | 5 + src/ui/request/folder_editor.py | 73 +- src/ui/request/http_worker.py | 134 +++- .../request/request_editor/editor_widget.py | 28 +- src/ui/request/request_editor/scripts.py | 146 ++++ src/ui/request/response_viewer/popup_mixin.py | 100 +++ .../response_viewer/test_results_mixin.py | 144 ++++ .../request/response_viewer/viewer_widget.py | 72 +- src/ui/styling/theme.py | 7 +- tests/ui/dialogs/test_collection_runner.py | 462 ++++++++++++ tests/ui/request/test_request_editor.py | 8 +- .../ui/request/test_response_viewer_tests.py | 88 +++ tests/ui/test_main_window_save.py | 5 +- tests/unit/database/test_repository.py | 4 +- .../services/test_script_bridge_globals.py | 298 ++++++++ tests/unit/services/test_script_engine.py | 570 ++++++++++++++ tests/unit/services/test_script_sandbox.py | 226 ++++++ tests/unit/services/test_script_service.py | 191 +++++ 57 files changed, 7224 insertions(+), 256 deletions(-) create mode 100644 data/scripts/pm_bootstrap.js create mode 100644 docs/api-reference/services/script-engine.md create mode 100644 docs/api-reference/services/script-service.md create mode 100644 docs/guides/writing-scripts.md create mode 100644 docs/scripting/collection-runner.md create mode 100644 docs/scripting/examples.md create mode 100644 docs/scripting/javascript-api.md create mode 100644 docs/scripting/overview.md create mode 100644 docs/scripting/python-api.md create mode 100644 docs/scripting/security.md create mode 100644 src/services/script_service.py create mode 100644 src/services/scripting/__init__.py create mode 100644 src/services/scripting/_py_sandbox.py create mode 100644 src/services/scripting/context.py create mode 100644 src/services/scripting/engine.py create mode 100644 src/services/scripting/js_runtime.py create mode 100644 src/services/scripting/py_runtime.py create mode 100644 src/ui/request/request_editor/scripts.py create mode 100644 src/ui/request/response_viewer/popup_mixin.py create mode 100644 src/ui/request/response_viewer/test_results_mixin.py create mode 100644 tests/ui/dialogs/test_collection_runner.py create mode 100644 tests/ui/request/test_response_viewer_tests.py create mode 100644 tests/unit/services/test_script_bridge_globals.py create mode 100644 tests/unit/services/test_script_engine.py create mode 100644 tests/unit/services/test_script_sandbox.py create mode 100644 tests/unit/services/test_script_service.py diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md index 1070783..0c28ebf 100644 --- a/.github/copilot-instructions.md +++ b/.github/copilot-instructions.md @@ -172,6 +172,13 @@ src/ │ ├── collection_service.py # CollectionService (static methods) │ ├── environment_service.py # EnvironmentService (variable substitution + TypedDicts) │ ├── import_service.py # ImportService (parse + persist) +│ ├── script_service.py # ScriptService (script chain resolution) +│ ├── scripting/ # Script execution sub-package +│ │ ├── __init__.py # TypedDicts (ScriptInput/Output, TestResult, etc.) +│ │ ├── engine.py # ScriptEngine (run chains, merge outputs) +│ │ ├── context.py # Context builders + normalize_events() + execute_sub_request() + globals persistence +│ │ ├── js_runtime.py # JSRuntime (V8 via PyMiniRacer) +│ │ └── py_runtime.py # PyRuntime (RestrictedPython subprocess) │ ├── http/ # HTTP request/response handling │ │ ├── http_service.py # HttpService (httpx) + response TypedDicts │ │ ├── graphql_schema_service.py # GraphQL introspection + schema parsing @@ -256,10 +263,12 @@ src/ │ ├── editor_widget.py # RequestEditor — main request editing widget │ ├── auth.py # Re-export of _AuthMixin from auth sub-package │ ├── body_search.py # _BodySearchMixin — search/replace in body - │ └── graphql.py # _GraphQLMixin — GraphQL mode + schema + │ ├── graphql.py # _GraphQLMixin — GraphQL mode + schema + │ └── scripts.py # _ScriptsMixin — dual pre-request/test script editors ├── response_viewer/ # ResponseViewer sub-package │ ├── viewer_widget.py # ResponseViewer — response display widget - │ └── search_filter.py # _SearchFilterMixin — response search/filter + │ ├── search_filter.py # _SearchFilterMixin — response search/filter + │ └── test_results_mixin.py # _TestResultsMixin — test results tab ├── navigation/ # Tab switching and path navigation │ ├── breadcrumb_bar.py │ ├── request_tab_bar.py # Compatibility wrapper re-exporting the wrapped deck @@ -285,6 +294,10 @@ tests/ │ ├── test_environment_service.py │ ├── test_import_parser.py │ ├── test_import_service.py +│ ├── test_script_bridge_globals.py +│ ├── test_script_engine.py +│ ├── test_script_sandbox.py +│ ├── test_script_service.py │ └── http/ # HTTP service tests │ ├── test_http_service.py │ ├── test_graphql_schema_service.py @@ -327,6 +340,7 @@ tests/ │ ├── test_collection_widget.py │ └── test_new_item_popup.py ├── dialogs/ # Dialog tests + │ ├── test_collection_runner.py │ ├── test_import_dialog.py │ ├── test_save_request_dialog.py │ └── test_settings_dialog.py @@ -347,6 +361,7 @@ tests/ ├── test_request_editor_search.py ├── test_response_viewer.py ├── test_response_viewer_search.py + ├── test_response_viewer_tests.py ├── navigation/ # Tab and breadcrumb tests │ ├── test_breadcrumb_bar.py │ ├── test_request_tab_bar.py diff --git a/.github/instructions/architecture.instructions.md b/.github/instructions/architecture.instructions.md index d092c1e..7dee950 100644 --- a/.github/instructions/architecture.instructions.md +++ b/.github/instructions/architecture.instructions.md @@ -84,6 +84,19 @@ RequestEditorWidget ──_on_fetch_schema──► SchemaFetchWorker (QThread Do not add instance state without updating every call site. - `EnvironmentService`, `HttpService`, `GraphQLSchemaService`, and `SnippetGenerator` follow the same `@staticmethod` pattern. +- `ScriptService` and `ScriptEngine` also follow the `@staticmethod` + pattern. `ScriptService.build_script_chain(request_id)` walks the + ancestor chain to collect inherited scripts. `ScriptEngine` dispatches + to `JSRuntime` (V8 via PyMiniRacer) or `PyRuntime` (RestrictedPython + subprocess). TypedDicts (`ScriptInput`, `ScriptOutput`, `TestResult`, + `ConsoleLog`, `ScriptEntry`) live in `services/scripting/__init__.py`. + `pm.sendRequest()` uses a host-side HTTP bridge (`execute_sub_request` + in `context.py`) with a trampoline loop (JS) or IPC protocol (Python). + The JS-side rate limit is 10 calls; the host enforces a hard cap of 50 + total sub-requests per execution (`_MAX_TOTAL_SUBREQUESTS`). Responses + larger than 10 MB are rejected (`_MAX_RESPONSE_BYTES`). + `pm.globals` are persisted to `data/globals.json` via `load_globals()` + / `save_globals()` in `context.py`. ## The dict interchange schema diff --git a/.github/instructions/testing.instructions.md b/.github/instructions/testing.instructions.md index 5912971..6b60b17 100644 --- a/.github/instructions/testing.instructions.md +++ b/.github/instructions/testing.instructions.md @@ -122,6 +122,10 @@ tests/ │ ├── test_environment_service.py │ ├── test_import_parser.py │ ├── test_import_service.py +│ ├── test_script_bridge_globals.py +│ ├── test_script_engine.py +│ ├── test_script_sandbox.py +│ ├── test_script_service.py │ └── http/ # HTTP service tests │ ├── test_http_service.py │ ├── test_graphql_schema_service.py @@ -164,6 +168,7 @@ tests/ │ ├── test_collection_widget.py │ └── test_new_item_popup.py ├── dialogs/ # Dialog tests + │ ├── test_collection_runner.py │ ├── test_import_dialog.py │ ├── test_save_request_dialog.py │ └── test_settings_dialog.py @@ -183,6 +188,7 @@ tests/ ├── test_request_editor_search.py ├── test_response_viewer.py ├── test_response_viewer_search.py + ├── test_response_viewer_tests.py ├── navigation/ # Tab and breadcrumb tests │ ├── test_breadcrumb_bar.py │ ├── test_request_tab_bar.py diff --git a/.github/skills/service-repository-reference/SKILL.md b/.github/skills/service-repository-reference/SKILL.md index 944d1d6..287a560 100644 --- a/.github/skills/service-repository-reference/SKILL.md +++ b/.github/skills/service-repository-reference/SKILL.md @@ -228,6 +228,37 @@ fragment capture), Password Credentials (direct POST), Client Credentials (direct POST). Browser-based flows open the system browser and start a local HTTP server to capture the callback. +### ScriptService + +All methods are `@staticmethod`. Resolves inherited script chains +by walking the collection ancestor tree. + +| Method | Returns | Purpose | +|--------|---------|---------| +| `build_script_chain(request_id)` | `tuple[list[ScriptEntry], list[ScriptEntry]]` | Collect pre-request and test scripts from ancestors + self | + +### ScriptEngine + +All methods are `@staticmethod`. Orchestrates script execution across +`JSRuntime` (V8/PyMiniRacer) and `PyRuntime` (RestrictedPython subprocess). + +| Method | Returns | Purpose | +|--------|---------|---------| +| `run_pre_request_scripts(chain, context)` | `ScriptOutput` | Run pre-request chain, merge outputs | +| `run_test_scripts(chain, context)` | `ScriptOutput` | Run test chain, merge outputs | +| `run_single(script, language, context)` | `ScriptOutput` | Run one script in specified runtime | + +Context builders and utilities in `services/scripting/context.py`: + +| Function | Purpose | +|----------|---------| +| `build_pre_request_context(...)` | Build `ScriptInput` for pre-request scripts | +| `build_test_context(...)` | Build `ScriptInput` for test/post-response scripts | +| `normalize_events(events)` | Convert Postman-style event list to `{pre_request, test}` dict | +| `execute_sub_request(spec)` | HTTP bridge for `pm.sendRequest()` (scheme whitelist, rate-limited) | +| `load_globals()` | Load persisted global variables from `data/globals.json` | +| `save_globals(changes)` | Merge changes into persisted globals file | + ## TypedDict schemas ### SnippetOptions (`services/http/snippet_generator/generator.py`) @@ -360,6 +391,45 @@ class ImportSummary(TypedDict): ... See `services/import_parser/models.py` for full field definitions. +### Scripting TypedDicts (`services/scripting/__init__.py`) + +```python +class ScriptInput(TypedDict): + request: dict[str, Any] # method, url, headers, body + response: dict[str, Any] # status, headers, body, elapsed_ms (test only) + variables: dict[str, str] # combined environment + collection vars + environment_vars: dict[str, str] # environment-scoped variables + collection_vars: dict[str, str] # collection-scoped variables + global_vars: NotRequired[dict[str, str]] # persisted global variables + info: dict[str, Any] # request name, iteration index + iteration_data: NotRequired[dict[str, Any]] # data-driven row (runner only) + +class ScriptOutput(TypedDict): + test_results: list[TestResult] # pm.test() assertion results + console_logs: list[ConsoleLog] # console.log/warn/error output + variable_changes: dict[str, str] # pm.variables/environment/collection changes + global_variable_changes: NotRequired[dict[str, str]] # pm.globals changes + request_mutations: dict[str, Any] | None # pm.request.* mutations + next_request: NotRequired[str | None] # pm.execution.setNextRequest() + skip_request: NotRequired[bool] # pm.execution.skipRequest() + +class ScriptEntry(TypedDict): + code: str # script source code + language: str # "javascript" or "python" + source_name: str # display label (e.g. "Collection > Test") + +class TestResult(TypedDict): + name: str # test description + passed: bool # assertion outcome + error: str | None # failure message + duration_ms: float # execution time + +class ConsoleLog(TypedDict): + level: str # "log", "warn", "error", "info" + message: str # formatted message + timestamp: float # time.time() value +``` + ### Theme TypedDict (`ui/styling/theme.py`) ```python diff --git a/.github/skills/signal-flow/SKILL.md b/.github/skills/signal-flow/SKILL.md index 2f1c1c9..c6bfc90 100644 --- a/.github/skills/signal-flow/SKILL.md +++ b/.github/skills/signal-flow/SKILL.md @@ -312,24 +312,35 @@ MainWindow settings_act.triggered ``` MainWindow run_act.triggered → _on_run_collection - → CollectionRunnerWidget(collection_id) - → CollectionRunnerWidget.progress(index, result_dict) - → CollectionRunnerWidget.finished(results_list) - → CollectionRunnerWidget.error(message) + → CollectionRunnerDialog(collection_id) + → _RunnerWorker.progress(index, result_dict) + → _RunnerWorker.finished(results_list) + → _RunnerWorker.error(message) ``` +Runner supports `pm.execution.setNextRequest()` / `skipRequest()` flow +control and data-driven iterations via CSV/JSON files. + ### Send request flow ``` RequestEditorWidget.send_requested → MainWindow._on_send_request() + → ScriptService.build_script_chain(request_id) + → (pre_chain, test_chain) → HttpSendWorker.set_request(method, url, headers, body, auth, settings) → QThread.started → HttpSendWorker.run() → EnvironmentService.substitute() (variable replacement) + → ScriptEngine.run_pre_request_scripts(pre_chain, context) + → apply request mutations (headers, body changes) → HttpService.send_request() (httpx + timing/network/size) + → ScriptEngine.run_test_scripts(test_chain, context) + → collect TestResult list + ConsoleLog list → HttpSendWorker.finished(HttpResponseDict) → MainWindow._on_response_ready(data) → ResponseViewerWidget.load_response(data) + → ResponseViewerWidget.load_test_results(results) + → ConsolePanelWidget.append_logs(console) → HttpSendWorker.error(str) → ResponseViewerWidget.show_error(message) ``` @@ -507,9 +518,9 @@ All other signals in the flow diagrams above are fully wired. | `ImportDialog._ImportWorker` | `error` | `Signal(str)` | | `ImportDialog._DropZone` | `files_dropped` | `Signal(list)` | | `ImportDialog` | `import_completed` | `Signal()` | -| `CollectionRunnerWidget` | `progress` | `Signal(int, dict)` | -| `CollectionRunnerWidget` | `finished` | `Signal(list)` | -| `CollectionRunnerWidget` | `error` | `Signal(str)` | +| `_RunnerWorker` | `progress` | `Signal(int, dict)` | +| `_RunnerWorker` | `finished` | `Signal(list)` | +| `_RunnerWorker` | `error` | `Signal(str)` | ### Other widgets diff --git a/data/scripts/pm_bootstrap.js b/data/scripts/pm_bootstrap.js new file mode 100644 index 0000000..429776f --- /dev/null +++ b/data/scripts/pm_bootstrap.js @@ -0,0 +1,701 @@ +// pm_bootstrap.js — Postmark JavaScript scripting API preamble. +// +// Injected before every user script. Provides the full pm.* API, +// console mock, and Chai-like assertion chains. +// +// After injection, __pm_context is set by the host with request, +// response, variables, environment, collection vars, and info. +// +// After user script runs, host reads JSON.stringify(__pm_state). + +"use strict"; + +// -- Internal state accumulator ---------------------------------------- + +var __pm_state = { + test_results: [], + console_logs: [], + variable_changes: {}, + global_variable_changes: {}, + request_mutations: null, + send_request_count: 0, + next_request: undefined, + skip_request: false, + _send_queue: [], +}; + +var __pm_callbacks = []; + +var __pm_context = { + request: {}, + response: null, + variables: {}, + environment_vars: {}, + collection_vars: {}, + global_vars: {}, + info: {}, + is_pre_request: true, +}; + +// -- Console mock (rate-limited to 200 messages) ----------------------- + +var __CONSOLE_LIMIT = 200; + +var console = { + _emit: function (level, args) { + if (__pm_state.console_logs.length >= __CONSOLE_LIMIT) return; + var parts = []; + for (var i = 0; i < args.length; i++) { + try { + parts.push( + typeof args[i] === "string" + ? args[i] + : JSON.stringify(args[i]) + ); + } catch (e) { + parts.push(String(args[i])); + } + } + __pm_state.console_logs.push({ + level: level, + message: parts.join(" "), + timestamp: Date.now() / 1000, + }); + }, + log: function () { + console._emit("log", arguments); + }, + warn: function () { + console._emit("warn", arguments); + }, + error: function () { + console._emit("error", arguments); + }, + info: function () { + console._emit("info", arguments); + }, +}; + +// -- Variable scope helper -------------------------------------------- + +function __makeVariableScope(initial, scopeName, changesKey) { + var store = {}; + var targetChanges = changesKey || "variable_changes"; + var keys = Object.keys(initial || {}); + for (var i = 0; i < keys.length; i++) { + store[keys[i]] = String(initial[keys[i]]); + } + + return { + get: function (key) { + return store.hasOwnProperty(key) ? store[key] : undefined; + }, + set: function (key, value) { + var strVal = String(value); + store[key] = strVal; + __pm_state[targetChanges][key] = strVal; + }, + has: function (key) { + return store.hasOwnProperty(key); + }, + unset: function (key) { + delete store[key]; + }, + toObject: function () { + var copy = {}; + var k = Object.keys(store); + for (var i = 0; i < k.length; i++) copy[k[i]] = store[k[i]]; + return copy; + }, + replaceIn: function (template) { + return template.replace(/\{\{(.+?)\}\}/g, function (m, key) { + return store.hasOwnProperty(key) ? store[key] : m; + }); + }, + }; +} + +// -- HeaderList helper ------------------------------------------------ + +function __makeHeaderList(headerArray, mutable) { + var headers = []; + if (headerArray) { + for (var i = 0; i < headerArray.length; i++) { + headers.push({ + key: headerArray[i].key || headerArray[i][0] || "", + value: headerArray[i].value || headerArray[i][1] || "", + }); + } + } + + var obj = { + get: function (name) { + var lower = name.toLowerCase(); + for (var i = 0; i < headers.length; i++) { + if (headers[i].key.toLowerCase() === lower) + return headers[i].value; + } + return undefined; + }, + has: function (name) { + return obj.get(name) !== undefined; + }, + toObject: function () { + var result = {}; + for (var i = 0; i < headers.length; i++) { + result[headers[i].key] = headers[i].value; + } + return result; + }, + }; + + if (mutable) { + obj.add = function (header) { + headers.push({ + key: header.key || "", + value: header.value || "", + }); + }; + obj.remove = function (name) { + var lower = name.toLowerCase(); + for (var i = headers.length - 1; i >= 0; i--) { + if (headers[i].key.toLowerCase() === lower) + headers.splice(i, 1); + } + }; + obj.upsert = function (header) { + var lower = (header.key || "").toLowerCase(); + for (var i = 0; i < headers.length; i++) { + if (headers[i].key.toLowerCase() === lower) { + headers[i].value = header.value || ""; + return; + } + } + headers.push({ + key: header.key || "", + value: header.value || "", + }); + }; + obj._toArray = function () { + return headers; + }; + } + + return obj; +} + +// -- Chai-like Expectation class -------------------------------------- + +function __Expectation(value) { + this._value = value; + this._not = false; +} + +// Chainable no-op getters for readability +var __chains = [ + "to", + "be", + "been", + "is", + "that", + "which", + "and", + "has", + "have", + "with", + "at", + "of", + "same", + "but", + "does", + "deep", +]; +for (var __ci = 0; __ci < __chains.length; __ci++) { + (function (name) { + Object.defineProperty(__Expectation.prototype, name, { + get: function () { + return this; + }, + }); + })(__chains[__ci]); +} + +// Negation +Object.defineProperty(__Expectation.prototype, "not", { + get: function () { + this._not = !this._not; + return this; + }, +}); + +// Boolean/existence property assertions +var __propAssertions = { + true: function (v) { + return v === true; + }, + false: function (v) { + return v === false; + }, + null: function (v) { + return v === null; + }, + undefined: function (v) { + return v === undefined; + }, + NaN: function (v) { + return typeof v === "number" && isNaN(v); + }, + exist: function (v) { + return v !== null && v !== undefined; + }, + empty: function (v) { + if (typeof v === "string" || Array.isArray(v)) return v.length === 0; + if (v && typeof v === "object") return Object.keys(v).length === 0; + return false; + }, +}; + +var __propKeys = Object.keys(__propAssertions); +for (var __pi = 0; __pi < __propKeys.length; __pi++) { + (function (name, fn) { + Object.defineProperty(__Expectation.prototype, name, { + get: function () { + var result = fn(this._value); + if (this._not) result = !result; + if (!result) { + throw new Error( + "expected " + + JSON.stringify(this._value) + + (this._not ? " not " : " ") + + "to be " + + name + ); + } + return this; + }, + }); + })(__propKeys[__pi], __propAssertions[__propKeys[__pi]]); +} + +__Expectation.prototype._assert = function (result, msg) { + if (this._not) result = !result; + if (!result) throw new Error(msg); + return this; +}; + +// Method assertions +__Expectation.prototype.equal = function (expected) { + return this._assert( + this._value === expected, + "expected " + + JSON.stringify(this._value) + + (this._not ? " not " : " ") + + "to equal " + + JSON.stringify(expected) + ); +}; +__Expectation.prototype.equals = __Expectation.prototype.equal; +__Expectation.prototype.eq = __Expectation.prototype.equal; + +__Expectation.prototype.eql = function (expected) { + var pass = JSON.stringify(this._value) === JSON.stringify(expected); + return this._assert( + pass, + "expected " + + JSON.stringify(this._value) + + (this._not ? " not " : " ") + + "to deeply equal " + + JSON.stringify(expected) + ); +}; + +__Expectation.prototype.a = function (type) { + var actual; + if (Array.isArray(this._value)) actual = "array"; + else actual = typeof this._value; + return this._assert( + actual === type, + "expected " + + JSON.stringify(this._value) + + (this._not ? " not " : " ") + + "to be a " + + type + ); +}; +__Expectation.prototype.an = __Expectation.prototype.a; + +__Expectation.prototype.include = function (val) { + var pass = false; + if (typeof this._value === "string") pass = this._value.indexOf(val) !== -1; + else if (Array.isArray(this._value)) pass = this._value.indexOf(val) !== -1; + else if (this._value && typeof this._value === "object") + pass = val in this._value; + return this._assert( + pass, + "expected " + + JSON.stringify(this._value) + + (this._not ? " not " : " ") + + "to include " + + JSON.stringify(val) + ); +}; +__Expectation.prototype.includes = __Expectation.prototype.include; +__Expectation.prototype.contain = __Expectation.prototype.include; +__Expectation.prototype.contains = __Expectation.prototype.include; + +__Expectation.prototype.property = function (name, val) { + var has = this._value != null && this._value.hasOwnProperty(name); + if (arguments.length === 2) { + has = has && this._value[name] === val; + } + return this._assert( + has, + "expected " + + JSON.stringify(this._value) + + (this._not ? " not " : " ") + + "to have property " + + JSON.stringify(name) + + (arguments.length === 2 + ? " with value " + JSON.stringify(val) + : "") + ); +}; + +__Expectation.prototype.lengthOf = function (n) { + var len = this._value && this._value.length; + return this._assert( + len === n, + "expected length " + len + (this._not ? " not " : " ") + "to be " + n + ); +}; +__Expectation.prototype.length = __Expectation.prototype.lengthOf; + +__Expectation.prototype.above = function (n) { + return this._assert( + this._value > n, + "expected " + + this._value + + (this._not ? " not " : " ") + + "to be above " + + n + ); +}; +__Expectation.prototype.greaterThan = __Expectation.prototype.above; +__Expectation.prototype.gt = __Expectation.prototype.above; + +__Expectation.prototype.below = function (n) { + return this._assert( + this._value < n, + "expected " + + this._value + + (this._not ? " not " : " ") + + "to be below " + + n + ); +}; +__Expectation.prototype.lessThan = __Expectation.prototype.below; +__Expectation.prototype.lt = __Expectation.prototype.below; + +__Expectation.prototype.least = function (n) { + return this._assert( + this._value >= n, + "expected " + + this._value + + (this._not ? " not " : " ") + + "to be at least " + + n + ); +}; +__Expectation.prototype.gte = __Expectation.prototype.least; + +__Expectation.prototype.most = function (n) { + return this._assert( + this._value <= n, + "expected " + + this._value + + (this._not ? " not " : " ") + + "to be at most " + + n + ); +}; +__Expectation.prototype.lte = __Expectation.prototype.most; + +__Expectation.prototype.match = function (re) { + return this._assert( + re.test(this._value), + "expected " + + JSON.stringify(this._value) + + (this._not ? " not " : " ") + + "to match " + + re + ); +}; +__Expectation.prototype.matches = __Expectation.prototype.match; + +// HTTP-specific assertions +__Expectation.prototype.status = function (code) { + var actual = this._value; + if (actual && typeof actual === "object" && "code" in actual) + actual = actual.code; + return this._assert( + actual === code, + "expected status " + + actual + + (this._not ? " not " : " ") + + "to be " + + code + ); +}; + +__Expectation.prototype.header = function (name, value) { + var resp = this._value; + if (resp && typeof resp === "object" && resp.headers) { + var headerVal = resp.headers.get + ? resp.headers.get(name) + : resp.headers[name]; + if (arguments.length === 2) { + return this._assert( + headerVal === value, + "expected header " + + name + + " to be " + + JSON.stringify(value) + + " but got " + + JSON.stringify(headerVal) + ); + } + return this._assert( + headerVal !== undefined, + "expected response to have header " + JSON.stringify(name) + ); + } + return this._assert(false, "expected a response object with headers"); +}; + +__Expectation.prototype.jsonBody = function (path, value) { + var resp = this._value; + var body = resp; + if (resp && typeof resp === "object" && "body" in resp) { + body = + typeof resp.body === "string" ? JSON.parse(resp.body) : resp.body; + } + var parts = path.split("."); + var cur = body; + for (var i = 0; i < parts.length; i++) { + if (cur == null) { + cur = undefined; + break; + } + cur = cur[parts[i]]; + } + if (arguments.length === 2) { + return this._assert( + JSON.stringify(cur) === JSON.stringify(value), + "expected " + + path + + " to be " + + JSON.stringify(value) + + " but got " + + JSON.stringify(cur) + ); + } + return this._assert( + cur !== undefined, + "expected body to have path " + JSON.stringify(path) + ); +}; + +// -- pm object -------------------------------------------------------- + +var pm = { + info: __pm_context.info || {}, + + request: (function () { + var req = __pm_context.request || {}; + var obj = { + url: req.url || "", + method: req.method || "GET", + headers: __makeHeaderList(req.headers, __pm_context.is_pre_request), + body: req.body || "", + }; + return obj; + })(), + + response: (function () { + var res = __pm_context.response; + if (!res) return null; + var obj = { + code: res.status_code || res.code || 0, + status: res.status || "", + headers: __makeHeaderList(res.headers, false), + responseTime: res.response_time || res.responseTime || 0, + responseSize: res.response_size || res.responseSize || 0, + body: res.body || "", + json: function () { + return JSON.parse(typeof obj.body === "string" ? obj.body : ""); + }, + text: function () { + return typeof obj.body === "string" ? obj.body : String(obj.body); + }, + }; + return obj; + })(), + + variables: __makeVariableScope(__pm_context.variables, "variables"), + environment: __makeVariableScope( + __pm_context.environment_vars, + "environment" + ), + collectionVariables: __makeVariableScope( + __pm_context.collection_vars, + "collectionVariables" + ), + globals: __makeVariableScope(__pm_context.global_vars, "globals", "global_variable_changes"), + + test: function (name, fn) { + var start = Date.now(); + var result = { name: name, passed: true, error: null, duration_ms: 0 }; + try { + fn(); + } catch (e) { + result.passed = false; + result.error = e.message || String(e); + } + result.duration_ms = Date.now() - start; + __pm_state.test_results.push(result); + }, + + expect: function (value) { + return new __Expectation(value); + }, + + sendRequest: function (spec, callback) { + if (__pm_state.send_request_count >= 10) { + throw new Error("pm.sendRequest rate limit exceeded (max 10)"); + } + __pm_state.send_request_count++; + var idx = __pm_callbacks.length; + __pm_callbacks.push(callback || null); + var reqSpec = + typeof spec === "string" ? { url: spec, method: "GET" } : spec; + __pm_state._send_queue.push({ + spec: reqSpec, + callbackIndex: idx, + }); + }, + + cookies: (function () { + var cookies = {}; + if (__pm_context.response) { + var hdrs = __pm_context.response.headers || []; + for (var i = 0; i < hdrs.length; i++) { + if ( + (hdrs[i].key || "").toLowerCase() === "set-cookie" + ) { + var raw = hdrs[i].value || ""; + var eqIdx = raw.indexOf("="); + if (eqIdx > 0) { + var cName = raw.substring(0, eqIdx).trim(); + var rest = raw.substring(eqIdx + 1); + var semiIdx = rest.indexOf(";"); + var cVal = + semiIdx >= 0 + ? rest.substring(0, semiIdx).trim() + : rest.trim(); + cookies[cName] = cVal; + } + } + } + } + return { + get: function (name) { + return cookies.hasOwnProperty(name) + ? cookies[name] + : undefined; + }, + getAll: function () { + var result = []; + var keys = Object.keys(cookies); + for (var i = 0; i < keys.length; i++) { + result.push({ + name: keys[i], + value: cookies[keys[i]], + }); + } + return result; + }, + }; + })(), + + execution: { + setNextRequest: function (name) { + __pm_state.next_request = + name === null || name === undefined ? null : String(name); + }, + skipRequest: function () { + __pm_state.skip_request = true; + }, + }, + + iterationData: (function () { + var data = __pm_context.iteration_data || {}; + return { + get: function (key) { + return data.hasOwnProperty(key) ? data[key] : undefined; + }, + toObject: function () { + var copy = {}; + var keys = Object.keys(data); + for (var i = 0; i < keys.length; i++) { + copy[keys[i]] = data[keys[i]]; + } + return copy; + }, + has: function (key) { + return data.hasOwnProperty(key); + }, + }; + })(), +}; + +// -- sendRequest callback fulfillment --------------------------------- + +function __pm_fulfill_send(index, resp) { + var cb = __pm_callbacks[index]; + if (cb) { + try { + cb(resp.error || null, resp); + } catch (e) { + __pm_state.console_logs.push({ + level: "error", + message: + "[Script] sendRequest callback error: " + + (e.message || String(e)), + timestamp: Date.now() / 1000, + }); + } + } +} + +// -- Freeze read-only objects ----------------------------------------- + +Object.freeze(pm.info); +Object.freeze(pm.cookies); +Object.freeze(pm.execution); +Object.freeze(pm.iterationData); + +if (!__pm_context.is_pre_request) { + // In test context, request and response are read-only + if (pm.response) Object.freeze(pm.response); + Object.freeze(pm.request); +} else { + // In pre-request context, track request mutations + __pm_state.request_mutations = { + url: pm.request.url, + method: pm.request.method, + headers: pm.request.headers._toArray + ? pm.request.headers._toArray() + : [], + body: pm.request.body, + }; +} diff --git a/docs/README.md b/docs/README.md index 6573811..2d5af4f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -53,6 +53,8 @@ | [Auth Handler](api-reference/services/auth-handler.md) | `apply_auth()` — 12 authentication types | | [OAuth2Service](api-reference/services/oauth2-service.md) | OAuth 2.0 token exchange — 4 grant types | | [Import Parsers](api-reference/services/import-parsers.md) | Postman, cURL, and URL parser modules | +| [ScriptEngine](api-reference/services/script-engine.md) | Script execution engine, runtimes, context builders | +| [ScriptService](api-reference/services/script-service.md) | Script chain resolution from database ancestry | ### API Reference — Cross-Cutting @@ -85,6 +87,18 @@ | [Adding a Widget](guides/adding-widget.md) | New widget checklist and patterns | | [Writing Tests](guides/writing-tests.md) | Test patterns for each layer | | [Wiring Signals](guides/wiring-signals.md) | Signal declaration and MainWindow wiring | +| [Writing Scripts](guides/writing-scripts.md) | Pre-request and test scripts step-by-step | + +### Scripting + +| Page | Description | +|------|-------------| +| [Overview](scripting/overview.md) | Script types, execution order, language support, security summary | +| [JavaScript API](scripting/javascript-api.md) | Complete JS `pm` object reference | +| [Python API](scripting/python-api.md) | Complete Python `pm` object reference | +| [Examples](scripting/examples.md) | Side-by-side JS/Python real-world patterns | +| [Security](scripting/security.md) | Sandbox design, threat model, resource limits | +| [Collection Runner](scripting/collection-runner.md) | Runner-specific scripting, test aggregation | ### Contributing diff --git a/docs/api-reference/services/script-engine.md b/docs/api-reference/services/script-engine.md new file mode 100644 index 0000000..ae9d6ad --- /dev/null +++ b/docs/api-reference/services/script-engine.md @@ -0,0 +1,183 @@ +# ScriptEngine + +Script execution engine — orchestrates JS and Python runtimes. + +**Module:** `services/scripting/engine.py` +**Re-exported from:** `services/scripting/__init__.py` + +## Class: `ScriptEngine` + +All methods are `@staticmethod`. + +### `run_pre_request_scripts` + +```python +@staticmethod +def run_pre_request_scripts( + chain: list[ScriptEntry], + context: ScriptInput, +) -> ScriptOutput +``` + +Run pre-request scripts in top-down order (collection → folder → +request). Variable changes from earlier scripts propagate to later +ones. + +### `run_test_scripts` + +```python +@staticmethod +def run_test_scripts( + chain: list[ScriptEntry], + context: ScriptInput, +) -> ScriptOutput +``` + +Run test scripts in bottom-up order (request → folder → collection). + +### `run_single` + +```python +@staticmethod +def run_single( + script: str, + language: str, + context: ScriptInput, +) -> ScriptOutput +``` + +Run a single script without chain merging. Returns empty output for +blank scripts. + +## Class: `JSRuntime` + +**Module:** `services/scripting/js_runtime.py` + +### `execute` + +```python +@staticmethod +def execute(script: str, context: ScriptInput) -> ScriptOutput +``` + +Run JavaScript in a fresh V8 isolate (PyMiniRacer). Injects +`pm_bootstrap.js` preamble, sets context, executes script, extracts +state. Returns valid `ScriptOutput` even on error. + +- Timeout: 5000 ms +- Max memory: 64 MB + +## Class: `PyRuntime` + +**Module:** `services/scripting/py_runtime.py` + +### `execute` + +```python +@staticmethod +def execute(script: str, context: ScriptInput) -> ScriptOutput +``` + +Run Python in a sandboxed subprocess (`_py_sandbox.py`). Sends +`ScriptInput` as JSON via stdin, reads `ScriptOutput` from stdout. +Returns valid `ScriptOutput` even on error. + +- Timeout: 10 seconds (hard kill) +- Memory: 128 MB (`RLIMIT_AS`) +- CPU: 5 seconds (`RLIMIT_CPU`) + +## Context Builders + +**Module:** `services/scripting/context.py` + +### `build_pre_request_context` + +```python +def build_pre_request_context( + *, + method: str, + url: str, + headers: dict[str, str], + body: str, + variables: dict[str, str], + environment_vars: dict[str, str], + collection_vars: dict[str, str], + global_vars: dict[str, str] | None = None, + info: dict[str, Any], +) -> ScriptInput +``` + +Build context for pre-request scripts. `response` is `None`. + +### `build_test_context` + +```python +def build_test_context( + *, + request_data: dict[str, Any], + response_data: dict[str, Any], + variables: dict[str, str], + environment_vars: dict[str, str], + collection_vars: dict[str, str], + global_vars: dict[str, str] | None = None, + info: dict[str, Any], +) -> ScriptInput +``` + +Build context for test scripts. `response` is populated. + +### `apply_request_mutations` + +```python +def apply_request_mutations( + mutations: dict[str, Any] | None, + *, + method: str, + url: str, + headers: dict[str, str], + body: str, +) -> tuple[str, str, dict[str, str], str] +``` + +Apply pre-request script mutations. Returns +`(method, url, headers, body)`. + +### `apply_variable_changes` + +```python +def apply_variable_changes( + changes: dict[str, str], + local_overrides: dict[str, str], +) -> dict[str, str] +``` + +Merge variable changes into local overrides. Returns new dict. + +### `normalize_events` + +```python +def normalize_events(events: Any) -> dict[str, str] +``` + +Convert events from Postman list format or internal dict format to +`{"pre_request": "...", "test": "..."}`. + +### `execute_sub_request` + +```python +def execute_sub_request(spec: dict[str, Any]) -> dict[str, Any] +``` + +Execute a single HTTP sub-request for `pm.sendRequest()`. Validates +scheme whitelist (http/https only), parses Postman-style headers and +body, returns response dict or `{"error": "..."}`. + +### `load_globals` / `save_globals` + +```python +def load_globals() -> dict[str, str] +def save_globals(changes: dict[str, str]) -> None +``` + +Load/save global variables from `data/globals.json`. `save_globals` +merges changes into the existing file. \ No newline at end of file diff --git a/docs/api-reference/services/script-service.md b/docs/api-reference/services/script-service.md new file mode 100644 index 0000000..d3d8d22 --- /dev/null +++ b/docs/api-reference/services/script-service.md @@ -0,0 +1,53 @@ +# ScriptService + +Script chain resolution from database ancestry. + +**Module:** `services/script_service.py` +**Re-exported from:** `services/__init__.py` + +## Class: `ScriptService` + +All methods are `@staticmethod`. + +### `build_script_chain` + +```python +@staticmethod +def build_script_chain( + request_id: int, +) -> tuple[list[ScriptEntry], list[ScriptEntry]] +``` + +Build the complete script inheritance chain for a request. + +Walks the collection/folder ancestry tree from root to request and +collects all scripts with their language metadata. + +**Returns:** `(pre_request_chain, test_chain)` where each chain is a +list of `ScriptEntry` dicts ordered by execution priority. + +- Pre-request chain: collection → folder(s) → request (top-down). +- Test chain: request → folder(s) → collection (bottom-up). + +### `build_collection_script_chain` + +```python +@staticmethod +def build_collection_script_chain( + events: Any, +) -> tuple[list[ScriptEntry], list[ScriptEntry]] +``` + +Build script chains from raw events data (without database lookup). +Used for standalone collection/folder script execution. + +## TypedDict: `ScriptEntry` + +Defined in `services/scripting/__init__.py`: + +```python +class ScriptEntry(TypedDict): + code: str # Script source code + language: str # "javascript" or "python" + source_name: str # Display name of the source (collection/folder/request) +``` diff --git a/docs/api-reference/typedicts.md b/docs/api-reference/typedicts.md index dc5bd63..a567ee9 100644 --- a/docs/api-reference/typedicts.md +++ b/docs/api-reference/typedicts.md @@ -1,7 +1,6 @@ # TypedDict Catalogue -Complete reference of all `TypedDict` schemas used to pass structured -data across module boundaries. +All `TypedDict` schemas used to pass structured data across modules. ## Service Layer @@ -316,100 +315,19 @@ Summary of what was actually persisted to the database. Colour slots consumed by the global stylesheet and widget painting. Every field is a hex colour string. -**Neutral colours:** - -| Field | Description | -|-------|-------------| -| `bg` | Background | -| `bg_alt` | Alternate background | -| `text` | Primary text | -| `text_muted` | Muted/secondary text | -| `border` | Border | -| `hover_bg` | Hover state background | -| `hover_tree_bg` | Tree item hover | -| `selected_bg` | Selection highlight | -| `input_bg` | Input field background | - -**Semantic colours:** - -| Field | Description | -|-------|-------------| -| `accent` | Primary accent | -| `success` | Success/positive | -| `warning` | Warning | -| `danger` | Danger/error | -| `muted` | Muted/disabled | -| `delete` | Delete action | -| `head` | Heading | -| `options` | Options/settings | - -**Functional colours:** - -| Field | Description | -|-------|-------------| -| `sending` | In-flight request indicator | -| `breadcrumb_sep` | Breadcrumb separator | - -**Import dialog colours:** - -| Field | Description | -|-------|-------------| -| `drop_zone_border` | Drop zone border | -| `drop_zone_bg` | Drop zone background | -| `drop_zone_active_bg` | Drop zone active state | -| `import_success` | Import success message | -| `import_error` | Import error message | - -**Console colours:** - -| Field | Description | -|-------|-------------| -| `console_bg` | Console background | -| `console_text` | Console text | - -**Timing phase colours:** - -| Field | Description | -|-------|-------------| -| `timing_prepare` | Preparation phase | -| `timing_dns` | DNS resolution | -| `timing_tcp` | TCP connection | -| `timing_tls` | TLS handshake | -| `timing_ttfb` | Time to first byte | -| `timing_download` | Download | -| `timing_process` | Processing | - -**Variable colours:** - -| Field | Description | -|-------|-------------| -| `variable_highlight` | Variable highlight background | -| `variable_unresolved_highlight` | Unresolved variable highlight | -| `variable_unresolved_text` | Unresolved variable text | - -**Code editor colours:** - -| Field | Description | -|-------|-------------| -| `editor_bracket_match` | Matched bracket highlight | -| `editor_gutter_bg` | Line number gutter background | -| `editor_gutter_text` | Line number text | -| `editor_error_underline` | Error squiggle | -| `editor_fold_indicator` | Code folding indicator | -| `editor_string` | String literal | -| `editor_number` | Number literal | -| `editor_keyword` | Keyword | -| `editor_comment` | Comment | -| `editor_tag` | XML/HTML tag | -| `editor_attribute` | Attribute name | -| `editor_punctuation` | Punctuation | -| `editor_fold_highlight` | Folded code highlight | -| `editor_indent_guide` | Indentation guide | -| `editor_active_indent_guide` | Active indentation guide | -| `editor_error_gutter_bg` | Error gutter background | -| `editor_fold_badge_bg` | Fold badge background | -| `editor_fold_badge_text` | Fold badge text | -| `editor_whitespace_dot` | Whitespace indicator | +| Category | Fields | Examples | +|----------|--------|----------| +| Neutral | 9 | `bg`, `bg_alt`, `text`, `text_muted`, `border`, `hover_bg`, `selected_bg` | +| Semantic | 6 | `accent`, `success`, `warning`, `danger`, `muted`, `delete` | +| HTTP method | 8 | `head`, `options`, `get`, `post`, `put`, `patch`, `delete` | +| Functional | 2 | `sending`, `breadcrumb_sep` | +| Import dialog | 5 | `drop_zone_border`, `import_success`, `import_error` | +| Console | 2 | `console_bg`, `console_text` | +| Timing phases | 7 | `timing_prepare`, `timing_dns`, `timing_tcp`, `timing_tls` | +| Variable highlight | 3 | `variable_highlight`, `variable_unresolved_highlight` | +| Code editor | 18 | `editor_bracket_match`, `editor_string`, `editor_keyword` | + +See `src/ui/styling/theme.py` for the full field list. ### CollectionDict @@ -425,3 +343,58 @@ Nested dict flowing between collection fetcher and tree widget | `type` | `str` | "folder" or "request" | | `children` | `dict[str, CollectionDict]` | Nested items (folders only) | | `method` | `str` | HTTP method (requests only) | + +## Scripting Types + +Defined in `services/scripting/__init__.py`. See +[ScriptEngine](services/script-engine.md) for usage. + +### `ScriptInput` + +| Field | Type | Description | +|-------|------|-------------| +| `request` | `dict[str, Any]` | Request data (url, method, headers, body) | +| `response` | `dict[str, Any] \| None` | Response data (`None` in pre-request) | +| `variables` | `dict[str, str]` | Merged variable scope | +| `environment_vars` | `dict[str, str]` | Environment-scoped variables | +| `collection_vars` | `dict[str, str]` | Collection-scoped variables | +| `global_vars` | `dict[str, str]` | Global variables (persisted to disk) | +| `info` | `dict[str, Any]` | Execution metadata (name, iteration) | +| `iteration_data` | `dict[str, Any]` | Data-driven row (runner only, optional) | + +### `ScriptOutput` + +| Field | Type | Description | +|-------|------|-------------| +| `test_results` | `list[TestResult]` | `pm.test()` assertion results | +| `console_logs` | `list[ConsoleLog]` | Console output lines | +| `variable_changes` | `dict[str, str]` | Variable scope mutations | +| `global_variable_changes` | `dict[str, str]` | Global scope mutations (optional) | +| `request_mutations` | `dict[str, Any] \| None` | Request mutations (pre-request only) | +| `next_request` | `str \| None` | `setNextRequest()` target (optional) | +| `skip_request` | `bool` | `skipRequest()` flag (optional) | + +### `ScriptEntry` + +| Field | Type | Description | +|-------|------|-------------| +| `code` | `str` | Script source code | +| `language` | `str` | `"javascript"` or `"python"` | +| `source_name` | `str` | Display label (e.g. collection/folder name) | + +### `TestResult` + +| Field | Type | Description | +|-------|------|-------------| +| `name` | `str` | Test description | +| `passed` | `bool` | Assertion outcome | +| `error` | `str \| None` | Failure message | +| `duration_ms` | `float` | Execution time in milliseconds | + +### `ConsoleLog` + +| Field | Type | Description | +|-------|------|-------------| +| `level` | `str` | `"log"`, `"warn"`, `"error"`, or `"info"` | +| `message` | `str` | Formatted message | +| `timestamp` | `float` | UNIX timestamp | diff --git a/docs/architecture/data-flow.md b/docs/architecture/data-flow.md index 79a6819..63b7278 100644 --- a/docs/architecture/data-flow.md +++ b/docs/architecture/data-flow.md @@ -194,3 +194,29 @@ User clicks Save (or Ctrl+S) 5. Update breadcrumb bar 6. Refresh collection tree to show new request ``` + +## Script Execution Flow + +```text +User clicks Send (with scripts) + --> _SendPipelineMixin._on_send() + 1. Resolve variables (EnvironmentService) + 2. ScriptService.build_script_chain(request_id) + <-- (pre_chain, test_chain) + 3. HttpSendWorker.run() [QThread] + a. load_globals() from data/globals.json + b. ScriptEngine.run_pre_request_scripts(pre_chain, context) + <-- ScriptOutput (mutations, variable changes, global changes, console) + c. save_globals() if global_variable_changes present + d. apply_request_mutations() -- URL, method, headers, body + e. HttpService.send_request() + <-- HttpResponseDict + f. ScriptEngine.run_test_scripts(test_chain, context) + <-- ScriptOutput (test results, console, variable/global changes) + g. save_globals() if global_variable_changes present + h. Merge outputs into response dict + <-- finished signal(dict) + --> ResponseViewer.display_response() + load_test_results() + --> ConsolePanel.append_message() for each console log + --> Apply variable_changes to local_overrides +``` diff --git a/docs/architecture/database-layer.md b/docs/architecture/database-layer.md index 2594cfe..c685c9f 100644 --- a/docs/architecture/database-layer.md +++ b/docs/architecture/database-layer.md @@ -124,7 +124,7 @@ Several columns store structured data as JSON: | `RequestModel` | `headers` | `list[{"key": str, "value": str, "enabled": bool}]` | | `RequestModel` | `body_options` | `dict[str, Any]` (body encoding/format settings) | | `RequestModel` | `auth` | `{"type": str, ...type-specific fields}` | -| `RequestModel` | `scripts` | `{"pre": str, "post": str}` | +| `RequestModel` | `scripts` | `{"pre_request": str, "test": str, "language": str}` | | `RequestModel` | `settings` | `dict[str, Any]` | | `RequestModel` | `events` | `dict[str, Any]` | | `SavedResponseModel` | `headers` | `list[{"key": str, "value": str}]` | diff --git a/docs/architecture/service-layer.md b/docs/architecture/service-layer.md index cf215a5..acf7628 100644 --- a/docs/architecture/service-layer.md +++ b/docs/architecture/service-layer.md @@ -28,6 +28,8 @@ All services use the same pattern: | `GraphQLSchemaService` | `http/graphql_schema_service.py` | GraphQL introspection and schema parsing | | `SnippetGenerator` | `http/snippet_generator/generator.py` | Generate code snippets in 23 languages | | `OAuth2Service` | `http/oauth2_service.py` | OAuth 2.0 token exchange (4 grant types) | +| `ScriptEngine` | `scripting/engine.py` | Script execution — JS (V8) and Python (subprocess) | +| `ScriptService` | `script_service.py` | Script chain resolution from collection/folder ancestry | Additionally, `apply_auth()` in `http/auth_handler.py` is a standalone function (not a class) that injects authentication headers for 12 auth diff --git a/docs/guides/writing-scripts.md b/docs/guides/writing-scripts.md new file mode 100644 index 0000000..06d8a11 --- /dev/null +++ b/docs/guides/writing-scripts.md @@ -0,0 +1,126 @@ +# Writing Scripts + +Step-by-step guide for adding pre-request and test scripts to your +requests and collections. + +## Finding the Scripts Tab + +### Request-level scripts + +1. Open a request in the editor. +2. Click the **Scripts** tab (next to Auth, Headers, Body). +3. Two editors appear: **Pre-request** and **Test**. +4. Select a language from the dropdown (JavaScript or Python). + +### Collection/Folder-level scripts + +1. Click a collection or folder in the sidebar. +2. The folder editor shows **Pre-request Script** and **Test Script** + editors. +3. These scripts run for every request inside the collection/folder. + +## Choosing a Language + +| Factor | JavaScript | Python | +|--------|-----------|--------| +| Postman compatibility | Full | Partial (different naming) | +| Sandbox | V8 isolate | Subprocess + RestrictedPython | +| Timeout | 5 seconds | 5 seconds CPU | +| Default | Yes | No (opt-in) | + +Use JavaScript for Postman-imported collections. Use Python if you +prefer Python syntax. Both languages provide the same `pm.*` API. + +## Your First Test Script + +### Step 1: Write the script + +In the Test editor: + +```javascript +pm.test("Status is 200", function() { + pm.expect(pm.response.code).to.equal(200); +}); +``` + +### Step 2: Send the request + +Click **Send**. The script runs after the response arrives. + +### Step 3: View results + +Switch to the **Test Results** tab in the Response Viewer. You'll see: + +- A summary: `1/1 tests passed` +- A row for each test with a green check or red X. +- Error details for failed tests. + +## Your First Pre-request Script + +```javascript +// Set a timestamp variable +pm.variables.set("ts", Date.now().toString()); + +// Use {{ts}} in the URL or body +console.log("Timestamp set:", pm.variables.get("ts")); +``` + +The variable `{{ts}}` can now be used in the URL, headers, or body +with double-brace syntax. + +## Using Inherited Scripts + +1. Open a collection or folder. +2. Add a pre-request script (e.g., logging or auth setup). +3. Every request inside that collection/folder will run the script + before its own pre-request script. + +```text +Collection pre-request: console.log("Starting request") + Folder pre-request: pm.request.headers.upsert({key: "X-Custom", value: "1"}) + Request pre-request: pm.variables.set("id", "123") +``` + +## Debugging via Console Panel + +All `console.log()` (JavaScript) and `print()` (Python) output appears +in the Console Panel at the bottom of the window. + +Common debugging patterns: + +```javascript +console.log("Request URL:", pm.request.url); +console.log("Response body:", pm.response.text()); +console.log("Variables:", JSON.stringify(pm.variables.toObject())); +``` + +## Common Pitfalls + +### Script timeout + +Scripts have a 5-second timeout. Infinite loops or heavy computation +will be killed with a `Script timed out` error. + +### Missing response in pre-request + +`pm.response` is `null` in pre-request scripts. Accessing it will +cause a runtime error. + +### Python import statements + +Python scripts cannot use `import`. Use pre-injected functions instead: +`json_loads()`, `re_search()`, `hashlib_sha256()`, etc. See +[Python API](../scripting/python-api.md) for the full list. + +### RestrictedPython compilation errors + +Python scripts that use `getattr()` on private attributes, `exec()`, +`eval()`, or `open()` will fail at compilation time with a +`Compilation failed` error. + +## Related Pages + +- [JavaScript API Reference](../scripting/javascript-api.md) +- [Python API Reference](../scripting/python-api.md) +- [Examples](../scripting/examples.md) +- [Security](../scripting/security.md) diff --git a/docs/scripting/collection-runner.md b/docs/scripting/collection-runner.md new file mode 100644 index 0000000..3325e3a --- /dev/null +++ b/docs/scripting/collection-runner.md @@ -0,0 +1,120 @@ +# Collection Runner Scripting + +The Collection Runner executes all requests in a collection +sequentially. When scripts are present, the runner integrates them +into the execution lifecycle. + +## Execution Lifecycle + +For each request in the collection: + +```text +1. Fetch script chain (collection --> folders --> request) +2. Run pre-request scripts (top-down) +3. Check for pm.execution.skipRequest() -- skip HTTP if set +4. Apply request mutations from scripts +5. Send HTTP request (unless skipped) +6. Run test scripts (bottom-up) +7. Check for pm.execution.setNextRequest() -- override next request +8. Record test results and console output +9. Emit progress with per-request results +``` + +## Test Results Display + +The runner's results table includes a **Tests** column showing pass/fail +counts per request: + +| Column | Content | +|--------|---------| +| Name | Request name | +| Status | HTTP status code, `ERR`, or `SKIP` | +| Time (ms) | Response time | +| Tests | `passed/total` (color-coded green/red) | +| Result | `OK` or error message | + +A summary line shows aggregate totals: +`Done: N/M requests OK | Tests: P/T passed | E error(s)` + +## Implementation + +The runner worker (`_RunnerWorker` in `collection_runner.py`) fetches +script chains via `ScriptService.build_script_chain(request_id)` and +executes them via `ScriptEngine.run_pre_request_scripts()` and +`ScriptEngine.run_test_scripts()`. + +Pre-request script mutations (URL, method, headers, body) are applied +before the HTTP request is sent. + +## Variable Propagation + +Variables set by scripts in one request are currently scoped to that +request's execution. Cross-request variable sharing in the runner is +planned for a future release. + +## Flow Control + +The `pm.execution` API controls request ordering in the runner: + +- `pm.execution.setNextRequest(name)` — jump to the named request + after the current one finishes. Pass `null` / `None` to stop + the runner entirely. +- `pm.execution.skipRequest()` — skip the current request's HTTP + send. The request is recorded with status `0` and shown as + `SKIP` in the results table. + +Flow control is evaluated at the end of each request. The last +`setNextRequest()` call wins when multiple scripts set it. + +```javascript +// Test script: retry on failure +pm.test("Retry on 500", function() { + if (pm.response.code === 500) { + pm.execution.setNextRequest(pm.info.requestName); + } +}); +``` + +## Data-Driven Runs + +The runner supports data-driven iterations via CSV or JSON data files: + +1. Click **Data File…** to load a CSV or JSON file. +2. Each row becomes one iteration. +3. `pm.iterationData.get(key)` / `pm.iteration_data.get(key)` returns + the value for the current row. +4. The **Iterations** spinner adjusts the iteration count. + +### CSV format + +```csv +username,password +alice,secret1 +bob,secret2 +``` + +### JSON format + +```json +[ + {"username": "alice", "password": "secret1"}, + {"username": "bob", "password": "secret2"} +] +``` + +The runner repeats the full request sequence once per data row. +Progress bar and table show all iterations. + +## Global Script Toggle + +Script execution can be disabled globally in **Settings → Scripting**. +When disabled, pre-request and test scripts are skipped for both +single requests and collection runs. The setting is stored in +QSettings under `scripting/enabled`. + +## Related Pages + +- [Overview](overview.md) — execution order and inheritance +- [JavaScript API](javascript-api.md) — full JS pm reference +- [Python API](python-api.md) — full Python pm reference +- [Security](security.md) — sandbox and resource limits diff --git a/docs/scripting/examples.md b/docs/scripting/examples.md new file mode 100644 index 0000000..9623920 --- /dev/null +++ b/docs/scripting/examples.md @@ -0,0 +1,268 @@ +# Script Examples + +Side-by-side JavaScript and Python examples for common scripting +patterns. + +## Response Validation + +### Check status code and body + +**JavaScript:** + +```javascript +pm.test("Status 200", function() { + pm.expect(pm.response.code).to.equal(200); +}); + +pm.test("Has required fields", function() { + var data = pm.response.json(); + pm.expect(data).to.have.property("id"); + pm.expect(data).to.have.property("name"); + pm.expect(data.name).to.be.a("string"); +}); + +pm.test("Response time under 500ms", function() { + pm.expect(pm.response.responseTime).to.be.below(500); +}); +``` + +**Python:** + +```python +pm.test("Status 200", + lambda: pm.expect(pm.response.code).to.equal(200)) + +def check_fields(): + data = pm.response.json() + pm.expect(data).to.have.property("id") + pm.expect(data).to.have.property("name") + pm.expect(data["name"]).to.be.a("string") + +pm.test("Has required fields", check_fields) + +pm.test("Response time under 500ms", + lambda: pm.expect(pm.response.response_time).to.be.below(500)) +``` + +## Auth Token Chain + +Pre-request script that extracts a token from a prior login response +and injects it as a Bearer header. + +**JavaScript:** + +```javascript +// Pre-request: use stored token +var token = pm.variables.get("auth_token"); +if (token) { + pm.request.headers.upsert({ + key: "Authorization", + value: "Bearer " + token + }); +} +``` + +```javascript +// Test script on login endpoint: save token for later +pm.test("Login successful", function() { + pm.expect(pm.response.code).to.equal(200); + var body = pm.response.json(); + pm.expect(body).to.have.property("token"); + pm.variables.set("auth_token", body.token); +}); +``` + +**Python:** + +```python +# Pre-request: use stored token +token = pm.variables.get("auth_token") +if token: + pm.request.headers["Authorization"] = "Bearer " + token +``` + +```python +# Test script on login endpoint: save token +def save_token(): + pm.expect(pm.response.code).to.equal(200) + body = pm.response.json() + pm.expect(body).to.have.property("token") + pm.variables.set("auth_token", body["token"]) + +pm.test("Login successful", save_token) +``` + +## Dynamic Data Generation + +Pre-request script that sets dynamic values as variables. + +**JavaScript:** + +```javascript +pm.variables.set("timestamp", Date.now().toString()); +pm.variables.set("random_id", Math.random().toString(36).substring(2, 10)); + +// Use in URL: {{base_url}}/items?ts={{timestamp}} +// Use in body: {"id": "{{random_id}}"} +``` + +**Python:** + +```python +pm.variables.set("timestamp", datetime_now()) +pm.variables.set("hash", hashlib_sha256("seed-" + datetime_now())) + +# Use {{timestamp}} and {{hash}} in URL or body +``` + +## JSON Schema Validation + +**JavaScript:** + +```javascript +pm.test("User list schema", function() { + var data = pm.response.json(); + pm.expect(data).to.be.an("array"); + pm.expect(data).to.have.lengthOf(10); + + // Check first item shape + var first = data[0]; + pm.expect(first).to.have.property("id"); + pm.expect(first).to.have.property("email"); + pm.expect(first.email).to.include("@"); + pm.expect(first.id).to.be.a("number"); + pm.expect(first.id).to.be.above(0); +}); +``` + +**Python:** + +```python +def check_schema(): + data = pm.response.json() + pm.expect(data).to.be.a("list") + pm.expect(data).to.have.length_of(10) + + first = data[0] + pm.expect(first).to.have.property("id") + pm.expect(first).to.have.property("email") + pm.expect(first["email"]).to.include("@") + pm.expect(first["id"]).to.be.above(0) + +pm.test("User list schema", check_schema) +``` + +## Cookie Validation + +**JavaScript:** + +```javascript +pm.test("Session cookie set", function() { + pm.expect(pm.cookies.get("session_id")).to.exist; +}); + +pm.test("Cookie count", function() { + var all = pm.cookies.getAll(); + pm.expect(all.length).to.be.above(0); +}); +``` + +**Python:** + +```python +pm.test("Session cookie set", + lambda: pm.expect(pm.cookies.get("session_id")).to.exist) + +def check_cookies(): + cookies = pm.cookies.get_all() + pm.expect(len(cookies)).to.be.above(0) + +pm.test("Cookie count", check_cookies) +``` + +## Environment-Aware Scripts + +**JavaScript:** + +```javascript +var env = pm.environment.get("env_name"); +console.log("Running against:", env); + +if (env === "production") { + pm.test("HTTPS only", function() { + pm.expect(pm.request.url).to.include("https://"); + }); +} +``` + +**Python:** + +```python +env = pm.environment.get("env_name") +print("Running against:", env) + +if env == "production": + pm.test("HTTPS only", + lambda: pm.expect(pm.request.url).to.include("https://")) +``` + +## Variable Scoping + +```javascript +// Variables merge with this precedence (highest wins): +// local (script-set) > environment > collection > globals + +// Read from merged scope: +var value = pm.variables.get("api_key"); + +// Write to specific scopes: +pm.environment.set("env_specific", "value"); +pm.collectionVariables.set("collection_wide", "value"); +pm.globals.set("global_value", "value"); + +// Template substitution uses merged variables: +var url = pm.variables.replaceIn("{{base_url}}/{{version}}/users"); +``` + +## Negation Assertions + +**JavaScript:** + +```javascript +pm.test("Not unauthorized", function() { + pm.expect(pm.response.code).to.not.equal(401); + pm.expect(pm.response.code).to.not.equal(403); +}); +``` + +**Python:** + +```python +def check_not_auth_error(): + pm.expect(pm.response.code).not_.equal(401) + pm.expect(pm.response.code).not_.equal(403) + +pm.test("Not unauthorized", check_not_auth_error) +``` + +## Collection-Level Shared Setup + +Set a script at the collection level to run before every request: + +```javascript +// Collection pre-request script +console.log("Running:", pm.info.requestName); +pm.variables.set("run_timestamp", Date.now().toString()); +``` + +Set a collection-level test script for common assertions: + +```javascript +// Collection test script +pm.test("No server errors", function() { + pm.expect(pm.response.code).to.be.below(500); +}); +``` + +These scripts run automatically for every request in the collection, +thanks to script inheritance. diff --git a/docs/scripting/javascript-api.md b/docs/scripting/javascript-api.md new file mode 100644 index 0000000..8ee0588 --- /dev/null +++ b/docs/scripting/javascript-api.md @@ -0,0 +1,287 @@ +# JavaScript API Reference + +Complete reference for the `pm` object available in JavaScript scripts. + +## `pm.info` + +Read-only execution metadata. + +| Property | Type | Description | +|----------|------|-------------| +| `requestName` | `string` | Name of the current request | +| `requestId` | `string` | Database ID of the current request | +| `iteration` | `number` | Current iteration index (runner only) | +| `iterationCount` | `number` | Total iterations (runner only) | + +```javascript +console.log("Running: " + pm.info.requestName); +``` + +## `pm.request` + +The current HTTP request. **Mutable in pre-request scripts** — changes +are applied before sending. **Frozen in test scripts.** + +| Property | Type | Mutable | Description | +|----------|------|---------|-------------| +| `url` | `string` | pre-request | Full request URL | +| `method` | `string` | pre-request | HTTP method | +| `headers` | `HeaderList` | pre-request | Request headers | +| `body` | `string` | pre-request | Request body text | + +### HeaderList Methods + +| Method | Description | +|--------|-------------| +| `headers.get(name)` | Get header value (case-insensitive) | +| `headers.has(name)` | Check if header exists | +| `headers.toObject()` | Convert to `{key: value}` object | +| `headers.add({key, value})` | Add header (pre-request only) | +| `headers.remove(name)` | Remove header (pre-request only) | +| `headers.upsert({key, value})` | Add or update header (pre-request only) | + +```javascript +// Pre-request: add auth header +pm.request.headers.upsert({ + key: "Authorization", + value: "Bearer " + pm.variables.get("token") +}); +``` + +## `pm.response` + +The HTTP response (available in test scripts only; `null` in +pre-request). + +| Property | Type | Description | +|----------|------|-------------| +| `code` | `number` | HTTP status code | +| `status` | `string` | Status text | +| `headers` | `HeaderList` | Response headers (read-only) | +| `responseTime` | `number` | Elapsed time in ms | +| `responseSize` | `number` | Response size in bytes | +| `body` | `string` | Raw response body | + +| Method | Returns | Description | +|--------|---------|-------------| +| `json()` | `object` | Parse body as JSON | +| `text()` | `string` | Body as string | + +```javascript +pm.test("Response is JSON", function() { + var data = pm.response.json(); + pm.expect(data).to.be.an("object"); +}); +``` + +## `pm.variables` + +Current-scope variables (merged collection + environment + local). + +| Method | Returns | Description | +|--------|---------|-------------| +| `get(key)` | `string \| undefined` | Get variable value | +| `set(key, value)` | — | Set variable (persists as local override) | +| `has(key)` | `boolean` | Check if variable exists | +| `unset(key)` | — | Remove variable | +| `toObject()` | `object` | All variables as `{key: value}` | +| `replaceIn(template)` | `string` | Substitute `{{var}}` patterns | + +```javascript +pm.variables.set("timestamp", Date.now().toString()); +var url = pm.variables.replaceIn("{{base_url}}/users"); +``` + +## `pm.environment` + +Environment-scoped variables only. Same methods as `pm.variables`. + +## `pm.collectionVariables` + +Collection-scoped variables only. Same methods as `pm.variables`. + +## `pm.globals` + +Global variables persisted to disk across all collections and sessions. +Same methods as `pm.variables`. + +## `pm.test(name, fn)` + +Register a named test assertion. `fn` is called immediately — if it +throws, the test is marked as failed. + +```javascript +pm.test("Status code is 200", function() { + pm.expect(pm.response.code).to.equal(200); +}); +``` + +## `pm.expect(value)` + +Create a Chai BDD-style assertion chain. + +### Chainable No-ops + +These words exist only for readability and do nothing: +`to`, `be`, `been`, `is`, `that`, `which`, `and`, `has`, `have`, +`with`, `at`, `of`, `same`, `but`, `does`, `deep`. + +### Negation + +`.not` — inverts the next assertion. + +```javascript +pm.expect(404).to.not.equal(200); +``` + +### Assertions + +| Method | Description | +|--------|-------------| +| `.equal(val)` | Strict equality (`===`) | +| `.eql(val)` | Deep equality (JSON comparison) | +| `.a(type)` / `.an(type)` | Type check (`typeof` or `"array"`) | +| `.include(val)` | Substring, array element, or object key | +| `.property(name, [val])` | Own property check, optional value | +| `.lengthOf(n)` | `.length === n` | +| `.above(n)` | Greater than | +| `.below(n)` | Less than | +| `.least(n)` | Greater than or equal (`>=`) | +| `.most(n)` | Less than or equal (`<=`) | +| `.match(regex)` | RegExp test | +| `.status(code)` | HTTP status code assertion | +| `.header(name, [val])` | Response header assertion | +| `.jsonBody(path, [val])` | JSONPath dot-notation assertion | + +### Boolean/Existence Properties + +| Property | Description | +|----------|-------------| +| `.true` | Value is `true` | +| `.false` | Value is `false` | +| `.null` | Value is `null` | +| `.undefined` | Value is `undefined` | +| `.NaN` | Value is `NaN` | +| `.exist` | Value is not `null` and not `undefined` | +| `.empty` | String/array length is 0, or object has no keys | + +### Examples + +```javascript +pm.expect(pm.response.code).to.equal(200); +pm.expect(data).to.have.property("id"); +pm.expect(data.items).to.have.lengthOf(10); +pm.expect(data.name).to.be.a("string"); +pm.expect(pm.response).to.have.status(201); +pm.expect(pm.response).to.have.header("Content-Type", "application/json"); +``` + +## `pm.sendRequest(spec, callback)` + +Send an HTTP sub-request from within a script. The request is executed +by the host process (not the V8 isolate), so network access is +controlled and rate-limited. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `spec` | `string \| object` | URL string or request spec object | +| `callback` | `function(err, response)` | Called with the result | + +**Spec object fields:** + +| Field | Type | Default | Description | +|-------|------|---------|-------------| +| `url` | `string` | — | Target URL (http/https only) | +| `method` | `string` | `"GET"` | HTTP method | +| `header` | `[{key, value}]` | `[]` | Request headers | +| `body` | `string \| {mode, raw}` | — | Request body | + +**Limits:** 10 calls per script execution, http/https only, 10 s +timeout per sub-request. + +```javascript +pm.sendRequest("https://api.example.com/token", function(err, response) { + if (!err) { + pm.variables.set("token", response.json().token); + } +}); +``` + +## `pm.cookies` + +Cookie jar parsed from response `Set-Cookie` headers. + +| Method | Returns | Description | +|--------|---------|-------------| +| `get(name)` | `string \| undefined` | Get cookie value by name | +| `getAll()` | `[{name, value}]` | All parsed cookies | + +```javascript +pm.test("Session cookie set", function() { + pm.expect(pm.cookies.get("session_id")).to.exist; +}); +``` + +## `pm.execution` + +Runner flow control — available in both pre-request and test scripts. + +| Method | Description | +|--------|-------------| +| `setNextRequest(name)` | Override the next request in the collection runner | +| `skipRequest()` | Skip the current request's HTTP send | + +`setNextRequest(name)` sets the next request to execute by name. Pass +`null` to stop the runner after the current request. Only effective +inside the Collection Runner — ignored for single-request sends. + +`skipRequest()` prevents the HTTP request from being sent. The result +is recorded with status `0` and marked as skipped. + +```javascript +// Pre-request: skip requests that are drafts +if (pm.info.requestName.startsWith("DRAFT")) { + pm.execution.skipRequest(); +} + +// Test: jump to a specific request +pm.test("Chain to login", function() { + if (pm.response.code === 401) { + pm.execution.setNextRequest("Login"); + } +}); +``` + +## `pm.iterationData` + +Data-driven iteration row — populated when the Collection Runner is +configured with a CSV or JSON data file. + +| Method | Returns | Description | +|--------|---------|-------------| +| `get(key)` | `any` | Get value by column/key name | +| `toObject()` | `object` | All row data as a plain object | +| `has(key)` | `boolean` | Check if key exists in current row | + +```javascript +// Use iteration data for parameterised requests +var userId = pm.iterationData.get("user_id"); +pm.request.url = pm.variables.replaceIn("{{base_url}}/users/") + userId; +``` + +## `console` + +Console output captured and routed to the Console panel. Rate-limited +to 200 messages per script execution. + +| Method | Description | +|--------|-------------| +| `console.log(...)` | Log message | +| `console.warn(...)` | Warning message | +| `console.error(...)` | Error message | +| `console.info(...)` | Info message | + +```javascript +console.log("Request URL:", pm.request.url); +console.warn("Slow response:", pm.response.responseTime, "ms"); +``` diff --git a/docs/scripting/overview.md b/docs/scripting/overview.md new file mode 100644 index 0000000..9164884 --- /dev/null +++ b/docs/scripting/overview.md @@ -0,0 +1,119 @@ +# Scripting Overview + +Postmark supports pre-request and test scripts in both JavaScript and +Python. Scripts automate request setup, validate responses, and chain +API calls — turning Postmark from an HTTP client into a full API testing +tool. + +## Script Types + +### Pre-request Scripts + +Run **before** the HTTP request is sent. Use cases: + +- Mutate the request: URL, method, headers, body. +- Set variables for dynamic values (timestamps, tokens, UUIDs). +- Chain requests (fetch a token, then use it in the main request). + +### Test Scripts (Post-response) + +Run **after** the response is received. Use cases: + +- Assert status codes, headers, body content. +- Parse JSON response and validate structure. +- Set variables for downstream requests. + +## Execution Order (Inheritance) + +Scripts are inherited from parent collections and folders. Execution +follows a deterministic order: + +```text +Pre-request: Collection --> Folder(s) --> Request (top-down) +HTTP request: ---- send ---- +Test: Request --> Folder(s) --> Collection (bottom-up) +``` + +A request inside `Collection > Folder A > Folder B` runs: + +```text +1. Collection pre-request script +2. Folder A pre-request script +3. Folder B pre-request script +4. Request pre-request script +5. --- HTTP request sent --- +6. Request test script +7. Folder B test script +8. Folder A test script +9. Collection test script +``` + +Variable changes from earlier scripts propagate to later ones in the +chain. A collection-level pre-request script that sets `{{token}}` +makes it available in every downstream script. + +## Language Support + +| Language | Runtime | Default | Sandbox | +|----------|---------|---------|---------| +| JavaScript | V8 via PyMiniRacer | Yes | V8 isolate (5 s, 64 MB) | +| Python | Subprocess + RestrictedPython | No | Process isolation (5 s CPU, 128 MB) | + +JavaScript is the default because most existing Postman collections use +it. Python is opt-in via the language selector in the Scripts tab. + +Both languages provide the same `pm.*` API surface (with Pythonic naming +in the Python variant — `pm.collection_variables` instead of +`pm.collectionVariables`). + +## Quick Start + +### JavaScript test script + +```javascript +pm.test("Status is 200", function() { + pm.expect(pm.response.code).to.equal(200); +}); + +pm.test("Body contains user", function() { + var body = pm.response.json(); + pm.expect(body).to.have.property("name"); + pm.expect(body.name).to.be.a("string"); +}); +``` + +### Python test script + +```python +pm.test("Status is 200", + lambda: pm.expect(pm.response.code).to.equal(200)) + +def check_body(): + body = pm.response.json() + pm.expect(body).to.have.property("name") + pm.expect(body["name"]).to.be.a("string") + +pm.test("Body contains user", check_body) +``` + +## Security Model + +Scripts run in sandboxed environments with no filesystem, network, or +OS access. See [Security](security.md) for the full threat model, +resource limits, and sandbox architecture. + +## Where Scripts Live + +- **Request-level:** Stored in `RequestModel.scripts` JSON column as + `{"pre_request": "...", "test": "...", "language": "javascript"}`. +- **Collection/Folder-level:** Stored in `CollectionModel.events` JSON + column. Supports both Postman array format and internal dict format. + +## Related Pages + +- [JavaScript API Reference](javascript-api.md) +- [Python API Reference](python-api.md) +- [Examples](examples.md) +- [Security](security.md) +- [Collection Runner Scripting](collection-runner.md) +- [Writing Scripts Guide](../guides/writing-scripts.md) diff --git a/docs/scripting/python-api.md b/docs/scripting/python-api.md new file mode 100644 index 0000000..1134e28 --- /dev/null +++ b/docs/scripting/python-api.md @@ -0,0 +1,269 @@ +# Python API Reference + +Complete reference for the `pm` object available in Python scripts. +Python scripts use Pythonic naming (`snake_case`) and RestrictedPython +compilation. + +## `pm.info` + +Read-only execution metadata. + +| Property | Type | Description | +|----------|------|-------------| +| `pm.info.request_name` | `str` | Name of the current request | +| `pm.info.request_id` | `str` | Database ID of the current request | +| `pm.info.iteration` | `int` | Current iteration index (runner only) | +| `pm.info.iteration_count` | `int` | Total iterations (runner only) | + +## `pm.request` + +The current HTTP request. Mutable in pre-request scripts only. + +| Property | Type | Mutable | Description | +|----------|------|---------|-------------| +| `pm.request.url` | `str` | pre-request | Full request URL | +| `pm.request.method` | `str` | pre-request | HTTP method | +| `pm.request.headers` | `dict[str, str]` | pre-request | Headers dictionary | +| `pm.request.body` | `str` | pre-request | Request body | + +```python +# Pre-request: set auth header +pm.request.headers["Authorization"] = "Bearer " + pm.variables.get("token") +``` + +## `pm.response` + +The HTTP response (`None` in pre-request scripts). + +| Property | Type | Description | +|----------|------|-------------| +| `pm.response.code` | `int` | HTTP status code | +| `pm.response.status` | `str` | Status text | +| `pm.response.headers` | `dict[str, str]` | Response headers | +| `pm.response.response_time` | `float` | Elapsed time in ms | +| `pm.response.response_size` | `int` | Response size in bytes | + +| Method | Returns | Description | +|--------|---------|-------------| +| `json()` | `dict \| list` | Parse body as JSON | +| `text()` | `str` | Body as string | + +```python +pm.test("JSON response", lambda: pm.expect(pm.response.json()).to.be.a("dict")) +``` + +## `pm.variables` + +Current-scope variables. + +| Method | Returns | Description | +|--------|---------|-------------| +| `get(key)` | `str \| None` | Get variable value | +| `set(key, value)` | — | Set variable | +| `has(key)` | `bool` | Check if variable exists | +| `unset(key)` | — | Remove variable | +| `to_dict()` | `dict[str, str]` | All variables as dict | +| `replace_in(template)` | `str` | Substitute `{{var}}` patterns | + +```python +pm.variables.set("ts", datetime_now()) +url = pm.variables.replace_in("{{base_url}}/users") +``` + +## `pm.environment` + +Environment-scoped variables. Same methods as `pm.variables`. + +## `pm.collection_variables` + +Collection-scoped variables. Same methods as `pm.variables`. + +## `pm.globals` + +Global variables persisted to disk across all collections and sessions. +Same methods as `pm.variables`. + +## `pm.test(name, fn)` + +Register a named test. `fn` is a no-arg callable — exceptions mark +the test as failed. + +```python +pm.test("Status 200", lambda: pm.expect(pm.response.code).to.equal(200)) + +def check_body(): + data = pm.response.json() + pm.expect(data).to.have.property("id") +pm.test("Has ID", check_body) +``` + +## `pm.expect(value)` + +Create an assertion chain (Chai BDD-style with Python naming). + +### Chainable No-ops + +`to`, `be`, `been`, `have`, `has_`, `at`, `of`, `same`, `deep`. + +### Negation + +`not_` — inverts the next assertion. + +```python +pm.expect(404).not_.equal(200) +``` + +### Assertions + +| Method | Description | +|--------|-------------| +| `.equal(val)` | Equality | +| `.eql(val)` / `.deep_equal(val)` | Deep equality (JSON comparison) | +| `.a(type_name)` / `.an(type_name)` | Type check (`"string"`, `"int"`, `"list"`, etc.) | +| `.include(val)` / `.contain(val)` | Substring, element, or key | +| `.has_property(name, [val])` / `.property(name, [val])` | Dict key check | +| `.length_of(n)` | Length assertion | +| `.above(n)` | Greater than | +| `.below(n)` | Less than | +| `.least(n)` | `>=` comparison | +| `.most(n)` | `<=` comparison | +| `.match(pattern)` | Regex match (`re.search`) | +| `.status(code)` | HTTP status code | +| `.header(name, [val])` | Response header check | + +### Boolean/Existence Properties + +| Property | Description | +|----------|-------------| +| `.true` | Value is `True` | +| `.false` | Value is `False` | +| `.none` | Value is `None` | +| `.exist` | Value is not `None` | +| `.empty` | Length is 0 | + +## `pm.cookies` + +Cookie jar parsed from response `Set-Cookie` headers. + +| Method | Returns | Description | +|--------|---------|-------------| +| `get(name)` | `str \| None` | Get cookie value by name | +| `get_all()` | `list[dict]` | All cookies as `[{"name": ..., "value": ...}]` | + +```python +pm.test("Has session", lambda: pm.expect(pm.cookies.get("sid")).to.exist) +``` + +## `pm.send_request(spec, callback=None)` + +Send an HTTP sub-request from within a script. Communication uses +IPC — the sandbox subprocess has no direct network access. + +| Parameter | Type | Description | +|-----------|------|-------------| +| `spec` | `str \| dict` | URL string or request spec dict | +| `callback` | callable | Optional `fn(err, response)` callback | + +**Spec dict fields:** `url`, `method`, `header`/`headers`, `body`. + +**Limits:** 10 calls per execution, http/https only, 10 s timeout. + +```python +pm.send_request("https://api.example.com/token", lambda err, resp: ( + pm.variables.set("token", resp["body"]) if not err else None +)) +``` + +## `pm.execution` + +Runner flow control — available in both pre-request and test scripts. + +| Method | Description | +|--------|-------------| +| `set_next_request(name)` | Override the next request in the collection runner | +| `skip_request()` | Skip the current request's HTTP send | + +`set_next_request(name)` sets the next request to execute by name. +Pass `None` to stop the runner after the current request. Only +effective inside the Collection Runner — ignored for single sends. + +`skip_request()` prevents the HTTP request from being sent. + +```python +# Pre-request: conditionally skip +if pm.info.request_name.startswith("DRAFT"): + pm.execution.skip_request() + +# Test: chain to another request +pm.test("Chain on 401", lambda: ( + pm.execution.set_next_request("Login") + if pm.response.code == 401 + else None +)) +``` + +## `pm.iteration_data` + +Data-driven iteration row — populated when the Collection Runner is +configured with a CSV or JSON data file. + +| Method | Returns | Description | +|--------|---------|-------------| +| `get(key)` | `str \| None` | Get value by column/key name | +| `to_object()` | `dict` | All row data as a dict | +| `has(key)` | `bool` | Check if key exists in current row | + +```python +user_id = pm.iteration_data.get("user_id") +pm.request.url = pm.variables.replace_in("{{base_url}}/users/") + user_id +``` + +## Available Standard Library + +Scripts cannot use `import`. Instead, pre-imported functions are +available as top-level names: + +| Name | From | Description | +|------|------|-------------| +| `json_loads(s)` | `json` | Parse JSON string | +| `json_dumps(obj)` | `json` | Serialize to JSON string | +| `re_match(pattern, s)` | `re` | Match at start | +| `re_search(pattern, s)` | `re` | Search anywhere | +| `re_findall(pattern, s)` | `re` | Find all matches | +| `re_sub(pattern, repl, s)` | `re` | Regex replace | +| `math_ceil(x)` | `math` | Ceiling | +| `math_floor(x)` | `math` | Floor | +| `math_sqrt(x)` | `math` | Square root | +| `math_pow(x, y)` | `math` | Power | +| `math_log(x)` | `math` | Natural log | +| `math_pi` | `math` | Pi constant | +| `math_e` | `math` | Euler's number | +| `b64encode(data)` | `base64` | Base64 encode | +| `b64decode(data)` | `base64` | Base64 decode | +| `hashlib_md5(data)` | `hashlib` | MD5 hex digest | +| `hashlib_sha256(data)` | `hashlib` | SHA-256 hex digest | +| `datetime_now()` | `datetime` | Current UTC ISO timestamp | +| `datetime_utcnow()` | `datetime` | Alias for `datetime_now()` | +| `url_quote(s)` | `urllib.parse` | URL-encode string | +| `url_urlencode(d)` | `urllib.parse` | URL-encode dict | + +## `print()` + +`print()` output is captured and routed to the Console panel as +`console.log` messages. Rate-limited to 200 messages per execution. + +```python +print("Request URL:", pm.request.url) +print("Response:", pm.response.code) +``` + +## Blocked Operations + +The following are blocked by RestrictedPython and the restricted +builtins whitelist: + +- `import` statements +- `open()`, `exec()`, `eval()`, `__import__()` +- `getattr()` on `_`-prefixed attributes +- File I/O, network I/O, OS access +- `subprocess`, `os`, `sys` module access diff --git a/docs/scripting/security.md b/docs/scripting/security.md new file mode 100644 index 0000000..a05d158 --- /dev/null +++ b/docs/scripting/security.md @@ -0,0 +1,151 @@ +# Security + +Script execution uses defense-in-depth sandboxing for both JavaScript +and Python runtimes. This page documents the threat model, sandbox +architecture, and resource limits. + +## Threat Model + +Postmark is a desktop app — the user runs scripts on their own machine. +Primary threats: + +1. **Imported collections with malicious scripts** — a shared Postman + collection could contain scripts that exfiltrate data or damage the + filesystem. +2. **Copy-pasted scripts** from untrusted internet sources. +3. **Accidental damage** — infinite loops, memory exhaustion, or app + state corruption. + +Scripts are NOT like browser JavaScript. The user explicitly imports a +collection or writes a script. But users often don't read scripts +before running them, so defense-in-depth is mandatory. + +## JavaScript Sandbox (V8 Isolate) + +The JavaScript runtime uses PyMiniRacer, which embeds a V8 isolate. + +| Capability | Status | Notes | +|-----------|--------|-------| +| File system access | Blocked | V8 has no `fs` module | +| Network access | Blocked | No `fetch`, `XMLHttpRequest`, `net` | +| Process spawning | Blocked | No `child_process`, `exec` | +| `require` / `import` | Blocked | No module system | +| `eval()` | Available | Runs inside same isolate | +| Timers (`setTimeout`) | Not available | V8 isolate has no event loop | + +### Resource Limits + +| Resource | Limit | +|----------|-------| +| Execution time | 5 seconds | +| Memory (heap) | 64 MB | +| Console messages | 200 per execution | +| `pm.sendRequest()` calls | 10 (JS-side); 50 total (host-side) | +| Sub-request response size | 10 MB | + +Implementation: `src/services/scripting/js_runtime.py` — +`_TIMEOUT_MS = 5000`, `_MAX_MEMORY_BYTES = 67_108_864`. + +## Python Sandbox (Three-Layer Defense) + +### Layer 1: Subprocess Isolation + +Python scripts run in a separate subprocess +(`src/services/scripting/_py_sandbox.py`). A crash, exploit, or +resource exhaustion in the subprocess cannot affect the main Postmark +app. + +### Layer 2: RestrictedPython Compilation + +Scripts are compiled using `compile_restricted()` from RestrictedPython. +This performs AST-level blocking of: + +- `import` statements +- `exec()` and `eval()` calls +- Augmented attribute access (all `_`-prefixed attributes blocked) + +### Layer 3: Restricted Builtins + Resource Limits + +A minimal whitelist of builtins is provided. Dangerous builtins are +removed: + +**Blocked:** `open`, `__import__`, `exec`, `eval`, `compile`, +`globals`, `locals`, `vars`, `dir`, `delattr`, `setattr`, `getattr` +(on `_`-prefixed names), `breakpoint`, `exit`, `quit`, `help`, +`input`, `memoryview`, `object.__subclasses__`. + +**Allowed:** `abs`, `all`, `any`, `bool`, `dict`, `enumerate`, +`filter`, `float`, `int`, `isinstance`, `len`, `list`, `map`, `max`, +`min`, `range`, `reversed`, `round`, `set`, `sorted`, `str`, `sum`, +`tuple`, `type` (single-argument only), `zip`. + +### Python Resource Limits (Linux) + +| Resource | Limit | `rlimit` | +|----------|-------|----------| +| CPU time | 5 seconds | `RLIMIT_CPU` | +| Memory (address space) | 128 MB | `RLIMIT_AS` | +| File descriptors | 3 (stdin/stdout/stderr only) | `RLIMIT_NOFILE` | + +Implementation: `_py_sandbox.py::_apply_resource_limits()`. On +non-Linux systems, limits are best-effort (may not apply). + +### Attribute Guard + +The `_getattr_guard()` function rejects all access to `_`-prefixed +attributes. This prevents escape attempts via `__class__`, +`__subclasses__`, `__dict__`, etc. + +```text +pm.response.__class__ --> AttributeError: Attribute access denied: __class__ +``` + +## Safe Standard Library + +Python scripts cannot import modules. Instead, a curated set of +functions is pre-injected into the script namespace. See +[Python API Reference](python-api.md) for the full list. + +## Console Rate Limiting + +Both runtimes cap console output at 200 messages per execution. +Messages beyond the limit are silently dropped. + +## What Scripts CAN Do + +- Read request/response data via `pm.request` / `pm.response`. +- Set/get variables across scopes. +- Register named test assertions. +- Mutate the request in pre-request scripts. +- Parse JSON, use regex, compute hashes, encode/decode base64. +- Write to the Console panel via `console.log()` / `print()`. + +## What Scripts CANNOT Do + +- Access the filesystem. +- Make network requests (only via `pm.sendRequest()`, rate-limited, + http/https only, 10 MB response cap). +- Import arbitrary modules (Python) or require packages (JavaScript). +- Access Postmark's internal state or database. +- Spawn processes. +- Access environment variables or OS information. +- Persist data outside of variables. +- Run longer than the timeout allows. + +## For Contributors + +### Adding Sandbox Tests + +Every sandbox escape attempt MUST be covered by a test in +`tests/unit/services/test_script_sandbox.py`. Test categories: + +1. **Import blocking** — `import os`, `__import__("os")`. +2. **Attribute escape** — `__class__.__subclasses__()`. +3. **Builtin abuse** — `open()`, `exec()`, `eval()`. +4. **Resource exhaustion** — infinite loop, large allocation. +5. **getattr bypass** — `_`-prefixed attribute access. + +### Security Test Requirements + +Security tests are part of CI. If a sandbox test fails, the build +fails. New sandbox features must include escape-attempt tests. diff --git a/docs/ui-reference/dialogs.md b/docs/ui-reference/dialogs.md index 0e78a22..6edf984 100644 --- a/docs/ui-reference/dialogs.md +++ b/docs/ui-reference/dialogs.md @@ -68,3 +68,27 @@ Background `_RunnerWorker` on `QThread` with signals: | `progress` | `int, dict` | Single request completed (index, result) | | `finished` | `list` | All requests completed | | `error` | `str` | Fatal error | + +### Script Integration + +The runner executes inherited script chains for each request: + +1. `ScriptService.build_script_chain(request_id)` resolves + pre-request and test scripts from ancestors. +2. `ScriptEngine.run_pre_request_scripts()` runs before sending. +3. `ScriptEngine.run_test_scripts()` runs after receiving a response. +4. Variable mutations from `pm.environment.set()` propagate across + subsequent requests within the run. + +### Results Table + +| Column | Content | +|--------|---------| +| # | Request index | +| Name | Request name | +| Status | HTTP status code (colour-coded) | +| Time | Response time in ms | +| Tests | `N/M passed` (green if all pass, red otherwise) | + +The summary row aggregates total test pass/fail counts across all +requests in the run. diff --git a/docs/ui-reference/response-viewer.md b/docs/ui-reference/response-viewer.md index 1a9de60..2abab08 100644 --- a/docs/ui-reference/response-viewer.md +++ b/docs/ui-reference/response-viewer.md @@ -131,3 +131,30 @@ Connection-level details from `NetworkDict`: | Certificate CN | example.com | | Issuer | Let's Encrypt | | Valid until | 2025-06-01 | + +## Test Results Tab + +**Mixin:** `_TestResultsMixin` in `response_viewer/test_results_mixin.py` + +Displays results from `pm.test()` assertions run in test scripts. +The tab is hidden by default and shown only when test results are +present. + +### Layout + +- **Summary header:** `N/M tests passed` with green/red styling. +- **Scrollable list:** One row per test with: + - Green check (`✓`) or red cross (`✗`) icon. + - Test name. + - Error message (failed tests only). + +### API + +| Method | Description | +|--------|-------------| +| `load_test_results(results)` | Populate tab from `list[TestResult]` | +| `_clear_test_results_rows()` | Remove all rows | +| `_build_test_results_tab()` | Create tab widget (called once at init) | + +The tab is integrated via the `clear()` method — it resets to hidden +state when a new request is sent. diff --git a/pyproject.toml b/pyproject.toml index c73c759..4dca0c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -14,6 +14,8 @@ httpx = "^0.28.1" pygments = "^2.19" jsonpath-ng = "^1.8.0" lxml = "^6.0.2" +py-mini-racer = "^0.6.0" +RestrictedPython = "^7.4" [tool.poetry.group.dev.dependencies] pytest = "^8.0" diff --git a/src/database/models/collections/collection_query_repository.py b/src/database/models/collections/collection_query_repository.py index 5beec3c..7529eea 100644 --- a/src/database/models/collections/collection_query_repository.py +++ b/src/database/models/collections/collection_query_repository.py @@ -448,3 +448,51 @@ def _saved_response_to_dict(response: Any) -> dict[str, Any]: "original_request": response.original_request, "created_at": created_at, } + + +def get_script_chain(request_id: int) -> list[dict[str, Any]]: + """Walk from *request_id* to root and collect scripts/events at each level. + + Returns an ordered list from root (collection) to leaf (request). + Each entry has ``source`` (``"collection"`` or ``"request"``), + ``id``, ``name``, and ``scripts`` (raw events/scripts dict). + + Folder levels use ``CollectionModel.events``. The request level + uses ``RequestModel.scripts`` (falling back to ``RequestModel.events`` + for Postman-imported data). + """ + with get_session() as session: + req = session.get(RequestModel, request_id) + if req is None: + return [] + + # 1. Walk parent chain: nearest → root. + ancestor_layers: list[dict[str, Any]] = [] + coll = session.get(CollectionModel, req.collection_id) + while coll is not None: + ancestor_layers.append( + { + "source": "collection", + "id": coll.id, + "name": coll.name, + "scripts": coll.events, + } + ) + if coll.parent_id is None: + break + coll = session.get(CollectionModel, coll.parent_id) + + # 2. Reverse so root comes first (collection → folder → … → leaf). + ancestor_layers.reverse() + + # 3. Append the request itself. + ancestor_layers.append( + { + "source": "request", + "id": req.id, + "name": req.name, + "scripts": req.scripts or req.events, + } + ) + + return ancestor_layers diff --git a/src/services/__init__.py b/src/services/__init__.py index 669f0b5..b93287b 100644 --- a/src/services/__init__.py +++ b/src/services/__init__.py @@ -11,12 +11,28 @@ from services.collection_service import CollectionService, RequestLoadDict from services.environment_service import EnvironmentService, LocalOverride, VariableDetail from services.import_service import ImportService +from services.script_service import ScriptService +from services.scripting import ( + ConsoleLog, + ScriptEngine, + ScriptEntry, + ScriptInput, + ScriptOutput, + TestResult, +) __all__ = [ "CollectionService", + "ConsoleLog", "EnvironmentService", "ImportService", "LocalOverride", "RequestLoadDict", + "ScriptEngine", + "ScriptEntry", + "ScriptInput", + "ScriptOutput", + "ScriptService", + "TestResult", "VariableDetail", ] diff --git a/src/services/http/http_service.py b/src/services/http/http_service.py index df7913e..adcfa9e 100644 --- a/src/services/http/http_service.py +++ b/src/services/http/http_service.py @@ -89,6 +89,11 @@ class HttpResponseDict(TypedDict): # Network metadata network: NotRequired[NetworkDict] + # Scripting results (populated by HttpSendWorker after script execution) + test_results: NotRequired[list[dict[str, Any]]] + console_logs: NotRequired[list[dict[str, Any]]] + variable_changes: NotRequired[dict[str, str]] + def _phase_ms(trace_times: dict[str, float], *prefixes: str) -> float: """Compute the duration of a trace phase from start/complete timestamps. diff --git a/src/services/import_parser/models.py b/src/services/import_parser/models.py index c65195f..950ffc1 100644 --- a/src/services/import_parser/models.py +++ b/src/services/import_parser/models.py @@ -92,4 +92,5 @@ class ImportSummary(TypedDict): requests_imported: int responses_imported: int environments_imported: int + scripts_detected: int errors: list[str] diff --git a/src/services/import_service.py b/src/services/import_service.py index 9a9317e..a6fca1f 100644 --- a/src/services/import_service.py +++ b/src/services/import_service.py @@ -8,6 +8,7 @@ import logging from pathlib import Path +from typing import Any from database.models.collections.import_repository import import_collection_tree from database.models.environments.environment_repository import create_environment @@ -80,6 +81,7 @@ def _persist(result: ImportResult) -> ImportSummary: requests_imported=0, responses_imported=0, environments_imported=0, + scripts_detected=_count_scripts(result), errors=list(result.get("errors", [])), ) @@ -117,3 +119,28 @@ def _persist(result: ImportResult) -> ImportSummary: len(summary["errors"]), ) return summary + + +def _count_scripts(result: ImportResult) -> int: + """Count nodes with scripts in the parsed import data.""" + count = 0 + + def _has_scripts(node: dict[str, Any]) -> bool: + events = node.get("events") + if isinstance(events, list) and events: + return True + scripts = node.get("scripts") + return isinstance(scripts, dict) and bool(scripts) + + def _walk(node: dict[str, Any]) -> None: + nonlocal count + if _has_scripts(node): + count += 1 + children = node.get("items") or node.get("children") or [] + for child in children: + if isinstance(child, dict): + _walk(child) + + for coll in result.get("collections", []): + _walk(dict(coll)) + return count diff --git a/src/services/script_service.py b/src/services/script_service.py new file mode 100644 index 0000000..066094f --- /dev/null +++ b/src/services/script_service.py @@ -0,0 +1,107 @@ +"""Script chain resolution service. + +Builds the ordered list of script entries that must execute for a given +request, walking the collection → folder → … → request inheritance chain. +""" + +from __future__ import annotations + +import logging +from typing import Any + +from database.models.collections.collection_query_repository import get_script_chain +from services.scripting import ScriptEntry +from services.scripting.context import normalize_events + +logger = logging.getLogger(__name__) + +# Default language when no ``"language"`` key is present on an events dict. +_DEFAULT_LANGUAGE = "javascript" + + +class ScriptService: + """Resolve inherited script chains for a request or collection. + + All methods are ``@staticmethod`` — no instance state needed. + """ + + @staticmethod + def build_script_chain( + request_id: int, + ) -> tuple[list[ScriptEntry], list[ScriptEntry]]: + """Return ``(pre_request_chain, test_chain)`` for *request_id*. + + **Pre-request chain** is ordered top-down: collection → folder → request. + **Test chain** is ordered bottom-up: request → folder → collection + (matching Postman convention: the request's own tests run first). + + Each entry contains the script ``code``, ``language``, and + ``source_name`` (human-readable label for console output). + + Empty scripts are omitted from both chains. + """ + raw_chain = get_script_chain(request_id) + return _build_chains(raw_chain) + + @staticmethod + def build_collection_script_chain( + events: dict[str, Any] | None, + *, + name: str = "", + ) -> tuple[list[ScriptEntry], list[ScriptEntry]]: + """Build a single-level chain from inline events (no DB lookup). + + Used for draft requests that have no ``request_id`` yet. + """ + normalized = normalize_events(events) + pre: list[ScriptEntry] = [] + test: list[ScriptEntry] = [] + language = str(normalized.get("language", _DEFAULT_LANGUAGE)) + + pre_code = normalized.get("pre_request", "").strip() + if pre_code: + pre.append({"code": pre_code, "language": language, "source_name": name}) + + test_code = normalized.get("test", "").strip() + if test_code: + test.append({"code": test_code, "language": language, "source_name": name}) + + return pre, test + + +def _build_chains( + raw_chain: list[dict[str, Any]], +) -> tuple[list[ScriptEntry], list[ScriptEntry]]: + """Convert raw DB chain into ordered ``(pre_request, test)`` entry lists.""" + pre_entries: list[ScriptEntry] = [] + test_entries: list[ScriptEntry] = [] + + for layer in raw_chain: + normalized = normalize_events(layer.get("scripts")) + source_name = layer.get("name", "") + language = str(normalized.get("language", _DEFAULT_LANGUAGE)) + + pre_code = normalized.get("pre_request", "").strip() + if pre_code: + pre_entries.append( + { + "code": pre_code, + "language": language, + "source_name": source_name, + } + ) + + test_code = normalized.get("test", "").strip() + if test_code: + test_entries.append( + { + "code": test_code, + "language": language, + "source_name": source_name, + } + ) + + # Test chain runs bottom-up: request → folder → collection. + test_entries.reverse() + + return pre_entries, test_entries diff --git a/src/services/scripting/__init__.py b/src/services/scripting/__init__.py new file mode 100644 index 0000000..e99deee --- /dev/null +++ b/src/services/scripting/__init__.py @@ -0,0 +1,73 @@ +"""Script execution engine package. + +Re-exports public TypedDicts and runtime classes so callers can import +from a single location:: + + from services.scripting import ScriptEngine, TestResult, ScriptOutput +""" + +from __future__ import annotations + +from typing import Any, NotRequired, TypedDict + +from services.scripting.engine import ScriptEngine + + +class TestResult(TypedDict): + """Single test assertion result from ``pm.test()``.""" + + name: str + passed: bool + error: str | None + duration_ms: float + + +class ConsoleLog(TypedDict): + """Single console output line captured from script execution.""" + + level: str # "log", "warn", "error", "info" + message: str + timestamp: float + + +class ScriptInput(TypedDict): + """Data injected into the script runtime before execution.""" + + request: dict[str, Any] + response: dict[str, Any] | None + variables: dict[str, str] + environment_vars: dict[str, str] + collection_vars: dict[str, str] + global_vars: NotRequired[dict[str, str]] + info: dict[str, Any] + iteration_data: NotRequired[dict[str, Any]] + + +class ScriptOutput(TypedDict): + """Accumulated results extracted from the script runtime after execution.""" + + test_results: list[TestResult] + console_logs: list[ConsoleLog] + variable_changes: dict[str, str] + global_variable_changes: NotRequired[dict[str, str]] + request_mutations: dict[str, Any] | None + next_request: NotRequired[str | None] + skip_request: NotRequired[bool] + + +class ScriptEntry(TypedDict): + """Single script in an inheritance chain.""" + + code: str + language: str + source_name: str + + +__all__ = [ + "ConsoleLog", + "ScriptEngine", + "ScriptEntry", + "ScriptInput", + "ScriptOutput", + "TestResult", +] diff --git a/src/services/scripting/_py_sandbox.py b/src/services/scripting/_py_sandbox.py new file mode 100644 index 0000000..f9c1b79 --- /dev/null +++ b/src/services/scripting/_py_sandbox.py @@ -0,0 +1,612 @@ +"""Python sandbox worker — runs in a subprocess. + +Reads a JSON ``ScriptInput`` from stdin, compiles the user script with +``RestrictedPython``, executes it in a heavily restricted environment, +and writes a JSON ``ScriptOutput`` to stdout. + +Security layers: +1. **Subprocess isolation** — crash or exploit cannot affect the main app. +2. **RestrictedPython** — AST-level import/exec/eval blocking. +3. **Restricted builtins** — minimal whitelist, no ``open``/``__import__``. +4. **Attribute guard** — rejects all ``_``-prefixed attribute access. +5. **Resource limits** — CPU 5s, memory 128 MB, no new file descriptors. +""" + +from __future__ import annotations + +import json +import math +import re +import sys +import time +from base64 import b64decode, b64encode +from datetime import UTC, datetime +from hashlib import md5, sha256 +from typing import Any +from urllib.parse import quote, urlencode + +try: + from RestrictedPython import compile_restricted, safe_globals # type: ignore[import-untyped] + + _HAS_RESTRICTED = True +except ImportError: + _HAS_RESTRICTED = False + compile_restricted = None # type: ignore[assignment] + safe_globals = {} # type: ignore[assignment] + +_CPU_LIMIT_SEC = 5 +_MEM_LIMIT_BYTES = 134_217_728 # 128 MB + + +def _apply_resource_limits() -> None: + """Set CPU, memory, and file-descriptor limits.""" + try: + import resource + + resource.setrlimit(resource.RLIMIT_CPU, (_CPU_LIMIT_SEC, _CPU_LIMIT_SEC)) + resource.setrlimit(resource.RLIMIT_AS, (_MEM_LIMIT_BYTES, _MEM_LIMIT_BYTES)) + # Allow only stdin/stdout/stderr — no new file descriptors. + resource.setrlimit(resource.RLIMIT_NOFILE, (3, 3)) + except (ImportError, ValueError, OSError): + pass # Non-Linux or unprivileged — limits won't apply. + + +_CONSOLE_LIMIT = 200 +_console_logs: list[dict[str, Any]] = [] + + +def _console_emit(level: str, *args: object) -> None: + """Capture a console message (rate-limited).""" + if len(_console_logs) >= _CONSOLE_LIMIT: + return + parts = [] + for a in args: + try: + parts.append(str(a)) + except Exception: + parts.append("") + _console_logs.append({"level": level, "message": " ".join(parts), "timestamp": time.time()}) + + +def _getattr_guard(obj: object, name: str, default: Any = None) -> Any: + """Block access to underscore-prefixed attributes.""" + if name.startswith("_"): + msg = f"Attribute access denied: {name}" + raise AttributeError(msg) + return getattr(obj, name, default) + + +class _VariableScope: + """Mimics the JS ``pm.variables`` API.""" + + def __init__(self, initial: dict[str, str]) -> None: + self._store: dict[str, str] = dict(initial) + self._changes: dict[str, str] = {} + + def get(self, key: str) -> str | None: + """Get variable value by key.""" + return self._store.get(key) + + def set(self, key: str, value: str) -> None: + """Set variable value and record the change.""" + s = str(value) + self._store[key] = s + self._changes[key] = s + + def has(self, key: str) -> bool: + return key in self._store + + def unset(self, key: str) -> None: + self._store.pop(key, None) + + def to_dict(self) -> dict[str, str]: + return dict(self._store) + + def replace_in(self, template: str) -> str: + """Substitute ``{{var}}`` patterns in *template*.""" + import re as _re + + def _repl(m: re.Match[str]) -> str: + k = m.group(1) + return self._store.get(k, m.group(0)) + + return _re.sub(r"\{\{(.+?)\}\}", _repl, template) + + +class _Expectation: + """Chainable assertion object for ``pm.expect()``.""" + + _CHAIN_NOOPS = frozenset({"to", "be", "been", "have", "has_", "at", "of", "same", "deep"}) + + def __init__(self, value: Any) -> None: + self._value = value + self._negated = False + + def __getattr__(self, name: str) -> _Expectation: + """Return ``self`` for readability chains (to, be, have, …).""" + if name in _Expectation._CHAIN_NOOPS: + return self + msg = f"'_Expectation' has no attribute {name!r}" + raise AttributeError(msg) + + @property + def not_(self) -> _Expectation: + """Negate the next assertion.""" + self._negated = not self._negated + return self + + def _assert(self, result: bool, msg: str) -> _Expectation: + if self._negated: + result = not result + if not result: + raise AssertionError(msg) + return self + + def equal(self, expected: Any) -> _Expectation: + """Assert strict equality.""" + return self._assert( + self._value == expected, f"expected {self._value!r} to equal {expected!r}" + ) + + def eql(self, expected: Any) -> _Expectation: + """Assert deep equality (via JSON round-trip).""" + a = json.dumps(self._value, sort_keys=True, default=str) + b = json.dumps(expected, sort_keys=True, default=str) + return self._assert(a == b, f"expected {self._value!r} to deeply equal {expected!r}") + + deep_equal = eql + + def a(self, type_name: str) -> _Expectation: + """Assert type.""" + type_map: dict[str, type | tuple[type, ...]] = { + "string": str, + "str": str, + "number": (int, float), + "int": int, + "float": float, + "boolean": bool, + "bool": bool, + "list": list, + "array": list, + "dict": dict, + "object": dict, + } + expected_type = type_map.get(type_name.lower()) + if expected_type: + ok = isinstance(self._value, expected_type) + else: + ok = type(self._value).__name__ == type_name + return self._assert(ok, f"expected {self._value!r} to be a {type_name}") + + an = a + + def include(self, val: Any) -> _Expectation: + """Assert inclusion (substring, element, or key).""" + ok = val in self._value if isinstance(self._value, str | list | tuple | dict) else False + return self._assert(ok, f"expected {self._value!r} to include {val!r}") + + contain = include + + _MISSING = object() + + def has_property(self, name: str, value: Any = _MISSING) -> _Expectation: + """Assert own property existence, optionally with value. + + Exposed to scripts as both ``has_property`` and ``property`` + (the latter is aliased after the class definition to avoid + shadowing the built-in ``property`` descriptor). + """ + has = isinstance(self._value, dict) and name in self._value + if value is not _Expectation._MISSING: + has = has and self._value.get(name) == value + return self._assert(has, f"expected {self._value!r} to have property {name!r}") + + def length_of(self, n: int) -> _Expectation: + """Assert length.""" + length = len(self._value) if hasattr(self._value, "__len__") else 0 + return self._assert(length == n, f"expected length {length} to be {n}") + + def above(self, n: float) -> _Expectation: + """Assert greater than.""" + return self._assert(self._value > n, f"expected {self._value} to be above {n}") + + def below(self, n: float) -> _Expectation: + """Assert less than.""" + return self._assert(self._value < n, f"expected {self._value} to be below {n}") + + def least(self, n: float) -> _Expectation: + """Assert greater than or equal.""" + return self._assert(self._value >= n, f"expected {self._value} to be at least {n}") + + def most(self, n: float) -> _Expectation: + """Assert less than or equal.""" + return self._assert(self._value <= n, f"expected {self._value} to be at most {n}") + + def match(self, pattern: str) -> _Expectation: + """Assert regex match.""" + ok = bool(re.search(pattern, str(self._value))) + return self._assert(ok, f"expected {self._value!r} to match {pattern!r}") + + def status(self, code: int) -> _Expectation: + """Assert HTTP status code.""" + actual = self._value + if isinstance(actual, dict) and "code" in actual: + actual = actual["code"] + return self._assert(actual == code, f"expected status {actual} to be {code}") + + def header(self, name: str, value: Any = None) -> _Expectation: + """Assert response header existence/value.""" + resp = self._value + if not isinstance(resp, dict) or "headers" not in resp: + return self._assert(False, "expected a response object with headers") + headers = resp["headers"] + lower = name.lower() + found = None + if isinstance(headers, dict): + for k, v in headers.items(): + if k.lower() == lower: + found = v + break + if value is not None: + return self._assert( + found == value, f"expected header {name} to be {value!r} but got {found!r}" + ) + return self._assert(found is not None, f"expected response to have header {name!r}") + + # -- Boolean property assertions -- + + @property + def true(self) -> _Expectation: + return self._assert(self._value is True, f"expected {self._value!r} to be True") + + @property + def false(self) -> _Expectation: + return self._assert(self._value is False, f"expected {self._value!r} to be False") + + @property + def none(self) -> _Expectation: + return self._assert(self._value is None, f"expected {self._value!r} to be None") + + @property + def exist(self) -> _Expectation: + return self._assert(self._value is not None, "expected value to exist") + + @property + def empty(self) -> _Expectation: + ok = len(self._value) == 0 if isinstance(self._value, str | list | tuple | dict) else False + return self._assert(ok, f"expected {self._value!r} to be empty") + + +# Alias so scripts can call ``pm.expect(x).to.have.property("key")`` +# without shadowing Python's built-in ``property`` descriptor inside the class. +_Expectation.property = _Expectation.has_property # type: ignore[attr-defined] + + +class _ConsolePrintCollector: + """Print collector for RestrictedPython's rewritten ``print()`` calls.""" + + def __init__(self, *args: object, **kwargs: object) -> None: + pass + + def _call_print(self, *args: object, **kwargs: object) -> None: + _console_emit("log", *args) + + +def _parse_cookie(raw: str) -> tuple[str, str] | None: + """Parse a single ``Set-Cookie`` header value into ``(name, value)``.""" + eq = raw.find("=") + if eq <= 0: + return None + name = raw[:eq].strip() + rest = raw[eq + 1 :] + semi = rest.find(";") + return name, (rest[:semi].strip() if semi >= 0 else rest.strip()) + + +class _PmCookies: + """Cookie jar parsed from response ``Set-Cookie`` headers.""" + + def __init__(self, response_data: dict[str, Any] | None) -> None: + self._cookies: dict[str, str] = {} + if response_data: + headers = response_data.get("headers") + if isinstance(headers, list): + for entry in headers: + if isinstance(entry, dict) and entry.get("key", "").lower() == "set-cookie": + parsed = _parse_cookie(entry.get("value", "")) + if parsed: + self._cookies[parsed[0]] = parsed[1] + elif isinstance(headers, dict): + for k, v in headers.items(): + if k.lower() == "set-cookie": + parsed = _parse_cookie(v) + if parsed: + self._cookies[parsed[0]] = parsed[1] + + def get(self, name: str) -> str | None: + """Get cookie value by name.""" + return self._cookies.get(name) + + def get_all(self) -> list[dict[str, str]]: + """Return all cookies as list of ``{name, value}`` dicts.""" + return [{"name": k, "value": v} for k, v in self._cookies.items()] + + +class _PmRequest: + """Mutable request representation for pre-request scripts.""" + + def __init__(self, data: dict[str, Any]) -> None: + self.url: str = data.get("url", "") + self.method: str = data.get("method", "GET") + self.headers: dict[str, str] = dict(data.get("headers", {})) + self.body: str = data.get("body", "") + + +class _PmResponse: + """Read-only response representation for test scripts.""" + + def __init__(self, data: dict[str, Any]) -> None: + self.code: int = data.get("status_code", data.get("code", 0)) + self.status: str = data.get("status", "") + self.headers: dict[str, str] = dict(data.get("headers", {})) + self.response_time: float = data.get("response_time", data.get("elapsed_ms", 0)) + self.response_size: int = data.get("response_size", data.get("size_bytes", 0)) + self._body: str = data.get("body", "") + + def json(self) -> Any: + return json.loads(self._body) + + def text(self) -> str: + return self._body + + +class _PmInfo: + """Execution metadata.""" + + def __init__(self, data: dict[str, Any]) -> None: + self.request_name = str(data.get("requestName", data.get("request_name", ""))) + self.request_id = str(data.get("requestId", data.get("request_id", ""))) + self.iteration = int(data.get("iteration", 0)) + self.iteration_count = int(data.get("iterationCount", data.get("iteration_count", 0))) + + +class _PmExecution: + """Flow control for ``setNextRequest`` / ``skipRequest``.""" + + def __init__(self) -> None: + self._next: str | None = None + self._next_set = self._skip = False + + def set_next_request(self, name: str | None = None) -> None: + self._next = str(name) if name is not None else None + self._next_set = True + + def skip_request(self) -> None: + self._skip = True + + +class _PmIterationData: + """Read-only iteration data for data-driven collection runs.""" + + def __init__(self, data: dict[str, Any]) -> None: + self._data = data + + def get(self, key: str) -> Any: + return self._data.get(key) + + def to_object(self) -> dict[str, Any]: + return dict(self._data) + + def has(self, key: str) -> bool: + return key in self._data + + +class _Pm: + """Root ``pm`` object injected into user scripts.""" + + def __init__(self, context: dict[str, Any]) -> None: + self.info = _PmInfo(context.get("info", {})) + self.request = _PmRequest(context.get("request", {})) + resp = context.get("response") + self.response: _PmResponse | None = _PmResponse(resp) if resp else None + self.cookies = _PmCookies(resp) + self.variables = _VariableScope(context.get("variables", {})) + self.environment = _VariableScope(context.get("environment_vars", {})) + self.collection_variables = _VariableScope(context.get("collection_vars", {})) + self.globals = _VariableScope(context.get("global_vars", {})) + self.execution = _PmExecution() + self.iteration_data = _PmIterationData(context.get("iteration_data", {})) + self._test_results: list[dict[str, Any]] = [] + self._is_pre_request: bool = resp is None + self._send_count = 0 + + def test(self, name: str, fn: Any) -> None: + start = time.time() + result: dict[str, Any] = {"name": name, "passed": True, "error": None, "duration_ms": 0.0} + try: + fn() + except Exception as e: + result["passed"] = False + result["error"] = str(e) + result["duration_ms"] = (time.time() - start) * 1000 + self._test_results.append(result) + + def expect(self, value: Any) -> _Expectation: + return _Expectation(value) + + def send_request(self, spec: Any, callback: Any = None) -> Any: + """Execute sub-request via IPC to the parent process.""" + if self._send_count >= 10: + msg = "pm.sendRequest rate limit exceeded (max 10)" + raise RuntimeError(msg) + self._send_count += 1 + req_spec: dict[str, Any] = ( + {"url": spec, "method": "GET"} if isinstance(spec, str) else dict(spec) + ) + _console_emit( + "log", + f'[Script] pm.sendRequest("{req_spec.get("method", "GET")} {req_spec.get("url", "")}")', + ) + sys.stdout.write(json.dumps({"__ipc__": "sendRequest", "spec": req_spec}) + "\n") + sys.stdout.flush() + resp_line = sys.stdin.readline() + if not resp_line: + msg = "No IPC response received" + raise RuntimeError(msg) + resp: dict[str, Any] = json.loads(resp_line) + if callback: + callback(resp.get("error"), resp) + return resp + + +def main() -> None: + """Read ScriptInput from stdin, execute script, write ScriptOutput to stdout.""" + _apply_resource_limits() + raw = sys.stdin.readline() + if not raw or not raw.strip(): + _write_done(_error_output("No input received")) + return + + try: + payload = json.loads(raw) + except json.JSONDecodeError as e: + _write_done(_error_output(f"Invalid JSON input: {e}")) + return + + script = payload.get("script", "") + context = payload.get("context", {}) + + pm = _Pm(context) + output = _execute_restricted(script, pm) + _write_done(output) + + +def _execute_restricted(script: str, pm: _Pm) -> dict[str, Any]: + """Compile and execute script in a restricted environment.""" + if not _HAS_RESTRICTED: + return _error_output("RestrictedPython is not installed") + + # 1. Compile with AST restrictions. + try: + code = compile_restricted(script, filename="