pytest-nexus 0.2.4__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pytest_nexus-0.2.4/.gitignore +74 -0
- pytest_nexus-0.2.4/LICENSE +21 -0
- pytest_nexus-0.2.4/PKG-INFO +121 -0
- pytest_nexus-0.2.4/README.md +102 -0
- pytest_nexus-0.2.4/pyproject.toml +36 -0
- pytest_nexus-0.2.4/pytest_nexus/__init__.py +5 -0
- pytest_nexus-0.2.4/pytest_nexus/plugin.py +484 -0
- pytest_nexus-0.2.4/tests/test_plugin.py +429 -0
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Secrets / local env
|
|
2
|
+
.env
|
|
3
|
+
.env.*
|
|
4
|
+
!.env.example
|
|
5
|
+
|
|
6
|
+
# Node / Forge
|
|
7
|
+
node_modules/
|
|
8
|
+
dist/
|
|
9
|
+
build/
|
|
10
|
+
# Anchored to repo root: this is Jest's coverage-report output directory, not to be
|
|
11
|
+
# confused with the app's own src/lib/coverage/ (Coverage Status derivation service,
|
|
12
|
+
# docs/build-plan.md task 3.3) — an unanchored `coverage/` here would silently
|
|
13
|
+
# gitignore that entire source module too.
|
|
14
|
+
/coverage/
|
|
15
|
+
*.log
|
|
16
|
+
npm-debug.log*
|
|
17
|
+
yarn-error.log*
|
|
18
|
+
.forge/
|
|
19
|
+
static/*/build/
|
|
20
|
+
static/*/dist/
|
|
21
|
+
static/*/node_modules/
|
|
22
|
+
|
|
23
|
+
# Python (template tooling in execution/)
|
|
24
|
+
__pycache__/
|
|
25
|
+
*.pyc
|
|
26
|
+
.venv/
|
|
27
|
+
venv/
|
|
28
|
+
|
|
29
|
+
# Session / scratch
|
|
30
|
+
.tmp/*
|
|
31
|
+
!.tmp/.gitkeep
|
|
32
|
+
|
|
33
|
+
# OS / editor
|
|
34
|
+
.DS_Store
|
|
35
|
+
Thumbs.db
|
|
36
|
+
*.swp
|
|
37
|
+
.idea/
|
|
38
|
+
.vscode/*
|
|
39
|
+
!.vscode/extensions.json
|
|
40
|
+
|
|
41
|
+
# forge tunnel local state
|
|
42
|
+
/.container-service-assets/
|
|
43
|
+
|
|
44
|
+
# Agent/browser scratch that must never be committed
|
|
45
|
+
.playwright-mcp/
|
|
46
|
+
/*.png
|
|
47
|
+
|
|
48
|
+
# Marketplace listing screenshots — kept on disk for the listing upload, not version-controlled
|
|
49
|
+
docs/marketplace/screenshots/
|
|
50
|
+
|
|
51
|
+
# Playwright test-runner output (frontend/tests/, added 2026-09-13)
|
|
52
|
+
frontend/playwright-report/
|
|
53
|
+
frontend/test-results/
|
|
54
|
+
|
|
55
|
+
# Local agent-skill tooling (taste-skill installer) — not app code
|
|
56
|
+
.agents/
|
|
57
|
+
skills-lock.json
|
|
58
|
+
|
|
59
|
+
# Generated Playwright runs (HTML reports, traces, artifacts): large, long paths, may hold session data
|
|
60
|
+
TESTING/evidence/*-local-playwright/
|
|
61
|
+
|
|
62
|
+
# Generated Playwright output anywhere under TESTING/evidence (traces, videos, HTML reports): large,
|
|
63
|
+
# over-long paths on Windows. Keep READMEs, results JSON and screenshots only.
|
|
64
|
+
TESTING/evidence/**/artifacts/
|
|
65
|
+
TESTING/evidence/**/html-report/
|
|
66
|
+
TESTING/evidence/**/test-results/
|
|
67
|
+
TESTING/evidence/**/*.webm
|
|
68
|
+
TESTING/evidence/**/trace.zip
|
|
69
|
+
TESTING/evidence/**/*html-report*/
|
|
70
|
+
TESTING/evidence/**/*artifacts*/
|
|
71
|
+
TESTING/evidence/**/*test-results*/
|
|
72
|
+
TESTING/evidence/**/*.zip
|
|
73
|
+
TESTING/evidence/**/*.webm
|
|
74
|
+
TESTING/evidence/**/*.trace
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Resync Consulting Limited
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: pytest-nexus
|
|
3
|
+
Version: 0.2.4
|
|
4
|
+
Summary: pytest plugin that sends each run's JUnit XML results to Nexus for Jira.
|
|
5
|
+
Project-URL: Homepage, https://nexus.resync.nz
|
|
6
|
+
Project-URL: Documentation, https://nexus.resync.nz/docs/automation/
|
|
7
|
+
Author: Resync Consulting Limited
|
|
8
|
+
License: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: jira,junit,nexus,pytest,test management
|
|
11
|
+
Classifier: Framework :: Pytest
|
|
12
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
13
|
+
Classifier: Operating System :: OS Independent
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Software Development :: Testing
|
|
16
|
+
Requires-Python: >=3.8
|
|
17
|
+
Requires-Dist: pytest>=7.0
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
|
|
20
|
+
# pytest-nexus
|
|
21
|
+
|
|
22
|
+
A pytest plugin that sends each run's results to **Nexus** for Jira. When the session
|
|
23
|
+
ends it posts the JUnit XML report to your project's Nexus results URL, where each test is
|
|
24
|
+
matched to a Test Case by its Automation Key.
|
|
25
|
+
|
|
26
|
+
Standard library only (plus pytest 7 or later). Python 3.8+.
|
|
27
|
+
|
|
28
|
+
## Install
|
|
29
|
+
|
|
30
|
+
From PyPI:
|
|
31
|
+
|
|
32
|
+
```sh
|
|
33
|
+
pip install pytest-nexus
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The same wheel and source archive are also on
|
|
37
|
+
[nexus.resync.nz/integrations](https://nexus.resync.nz/integrations/), with checksums in
|
|
38
|
+
[SHA256SUMS.txt](https://nexus.resync.nz/downloads/SHA256SUMS.txt).
|
|
39
|
+
|
|
40
|
+
The plugin loads automatically. It does nothing until a Nexus URL is configured.
|
|
41
|
+
|
|
42
|
+
## Configure
|
|
43
|
+
|
|
44
|
+
In Jira, open **Project Settings → Nexus → CI**, create an API key with the
|
|
45
|
+
**ingest** scope and copy the results URL. Store both as CI secrets:
|
|
46
|
+
|
|
47
|
+
| Environment variable | CLI option | Meaning |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| `NEXUS_URL` | `--nexus-url` | Results URL (required) |
|
|
50
|
+
| `NEXUS_API_KEY` | `--nexus-api-key` | API key (required; prefer the env var) |
|
|
51
|
+
| `NEXUS_PROJECT` | `--nexus-project` | Jira project key, e.g. `NEX` (required) |
|
|
52
|
+
| `NEXUS_ENVIRONMENT` | `--nexus-environment` | Environment label, e.g. `staging` |
|
|
53
|
+
| `NEXUS_TEST_SET` | `--nexus-test-set` | Test Set (cycle) to record into, e.g. `NEX-25` |
|
|
54
|
+
| `NEXUS_BUILD` | `--nexus-build` | Build id. Default: the CI system's build number |
|
|
55
|
+
| `NEXUS_COMMIT` | `--nexus-commit` | Commit. Default: the CI system's commit SHA |
|
|
56
|
+
| `NEXUS_COMPONENT` | `--nexus-component` | Component label, e.g. `api` |
|
|
57
|
+
| `NEXUS_BUILD_URL` | `--nexus-build-url` | CI job page (https), shown on each run as "Open CI job". Default: the CI system's own job page (0.2.3) |
|
|
58
|
+
| `NEXUS_IDEMPOTENCY_KEY` | `--nexus-idempotency-key` | Retry key, 16-128 of `A-Z a-z 0-9 . _ : -`. Default: a new random key per run (0.2.3) |
|
|
59
|
+
|
|
60
|
+
Other options:
|
|
61
|
+
|
|
62
|
+
- `--nexus-fail-on-upload-error`: fail the pytest run (exit code 1) when the upload
|
|
63
|
+
fails. By default an upload problem only prints a warning and the exit code is
|
|
64
|
+
whatever your tests produced.
|
|
65
|
+
- `--no-nexus`: turn the plugin off for one run.
|
|
66
|
+
|
|
67
|
+
Build, commit and the CI job page are filled automatically on GitHub Actions, GitLab,
|
|
68
|
+
Azure Pipelines, Bitbucket Pipelines and Jenkins (the job page also on CircleCI). A job page
|
|
69
|
+
that is not https is never sent.
|
|
70
|
+
|
|
71
|
+
## Name your tests with the Automation Key
|
|
72
|
+
|
|
73
|
+
Put the Test Case's Automation Key in the test name. Python names cannot contain `-`, so
|
|
74
|
+
use `_`; Nexus reads `NEX_11` as `NEX-11`:
|
|
75
|
+
|
|
76
|
+
```python
|
|
77
|
+
def test_NEX_11_valid_user_signs_in(driver):
|
|
78
|
+
...
|
|
79
|
+
```
|
|
80
|
+
|
|
81
|
+
Results whose name carries no known key are listed as *unmatched* in the delivery log.
|
|
82
|
+
|
|
83
|
+
## Example
|
|
84
|
+
|
|
85
|
+
```sh
|
|
86
|
+
export NEXUS_URL=... # from your CI secret store
|
|
87
|
+
export NEXUS_API_KEY=...
|
|
88
|
+
pytest --nexus-project NEX --nexus-environment staging
|
|
89
|
+
```
|
|
90
|
+
|
|
91
|
+
If you already pass `--junitxml=path.xml`, that file is the one sent. Otherwise the
|
|
92
|
+
plugin writes a temporary report and removes it afterwards. With pytest-xdist, only the
|
|
93
|
+
controller sends.
|
|
94
|
+
|
|
95
|
+
Output at the end of the run:
|
|
96
|
+
|
|
97
|
+
```
|
|
98
|
+
nexus: sent results to project NEX at <your-site>.atlassian-dev.net (HTTP 202). See Project Settings > CI for the delivery log.
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
Nexus answers `202` with `{"status":"accepted"}` only after it has processed every
|
|
102
|
+
result of the report (SEC27-H9, 28/09/2026: nothing is left running in the background
|
|
103
|
+
behind an "accepted"); the per-test outcome (matched, unmatched, skipped) is in the delivery
|
|
104
|
+
log on the CI page. A `{"status":"partial"}` answer means Nexus holds the report and what it
|
|
105
|
+
recorded so far and finishes the rest itself; the reporter sends it again to confirm.
|
|
106
|
+
The API key is never printed. Reports over 5 MB or 500 results must be split.
|
|
107
|
+
A report that reaches Nexus's time limit part-way (a 202 whose body says "partial", or a
|
|
108
|
+
500), a 502/503/504 or a network error is sent again, up to 8 sends, until Nexus has
|
|
109
|
+
recorded all of it; a resend never records a result twice (0.2.0, 27/09/2026). Since 0.2.3
|
|
110
|
+
every send of one report carries the same `Idempotency-Key` header, so Nexus treats a late
|
|
111
|
+
resend as the same delivery, never as a new run.
|
|
112
|
+
|
|
113
|
+
## Develop
|
|
114
|
+
|
|
115
|
+
```sh
|
|
116
|
+
python -m pytest # from this folder; uses a local fake Nexus, no network
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
## Licence
|
|
120
|
+
|
|
121
|
+
MIT. © 2026 Resync Consulting Limited. See `LICENSE`.
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
# pytest-nexus
|
|
2
|
+
|
|
3
|
+
A pytest plugin that sends each run's results to **Nexus** for Jira. When the session
|
|
4
|
+
ends it posts the JUnit XML report to your project's Nexus results URL, where each test is
|
|
5
|
+
matched to a Test Case by its Automation Key.
|
|
6
|
+
|
|
7
|
+
Standard library only (plus pytest 7 or later). Python 3.8+.
|
|
8
|
+
|
|
9
|
+
## Install
|
|
10
|
+
|
|
11
|
+
From PyPI:
|
|
12
|
+
|
|
13
|
+
```sh
|
|
14
|
+
pip install pytest-nexus
|
|
15
|
+
```
|
|
16
|
+
|
|
17
|
+
The same wheel and source archive are also on
|
|
18
|
+
[nexus.resync.nz/integrations](https://nexus.resync.nz/integrations/), with checksums in
|
|
19
|
+
[SHA256SUMS.txt](https://nexus.resync.nz/downloads/SHA256SUMS.txt).
|
|
20
|
+
|
|
21
|
+
The plugin loads automatically. It does nothing until a Nexus URL is configured.
|
|
22
|
+
|
|
23
|
+
## Configure
|
|
24
|
+
|
|
25
|
+
In Jira, open **Project Settings → Nexus → CI**, create an API key with the
|
|
26
|
+
**ingest** scope and copy the results URL. Store both as CI secrets:
|
|
27
|
+
|
|
28
|
+
| Environment variable | CLI option | Meaning |
|
|
29
|
+
|---|---|---|
|
|
30
|
+
| `NEXUS_URL` | `--nexus-url` | Results URL (required) |
|
|
31
|
+
| `NEXUS_API_KEY` | `--nexus-api-key` | API key (required; prefer the env var) |
|
|
32
|
+
| `NEXUS_PROJECT` | `--nexus-project` | Jira project key, e.g. `NEX` (required) |
|
|
33
|
+
| `NEXUS_ENVIRONMENT` | `--nexus-environment` | Environment label, e.g. `staging` |
|
|
34
|
+
| `NEXUS_TEST_SET` | `--nexus-test-set` | Test Set (cycle) to record into, e.g. `NEX-25` |
|
|
35
|
+
| `NEXUS_BUILD` | `--nexus-build` | Build id. Default: the CI system's build number |
|
|
36
|
+
| `NEXUS_COMMIT` | `--nexus-commit` | Commit. Default: the CI system's commit SHA |
|
|
37
|
+
| `NEXUS_COMPONENT` | `--nexus-component` | Component label, e.g. `api` |
|
|
38
|
+
| `NEXUS_BUILD_URL` | `--nexus-build-url` | CI job page (https), shown on each run as "Open CI job". Default: the CI system's own job page (0.2.3) |
|
|
39
|
+
| `NEXUS_IDEMPOTENCY_KEY` | `--nexus-idempotency-key` | Retry key, 16-128 of `A-Z a-z 0-9 . _ : -`. Default: a new random key per run (0.2.3) |
|
|
40
|
+
|
|
41
|
+
Other options:
|
|
42
|
+
|
|
43
|
+
- `--nexus-fail-on-upload-error`: fail the pytest run (exit code 1) when the upload
|
|
44
|
+
fails. By default an upload problem only prints a warning and the exit code is
|
|
45
|
+
whatever your tests produced.
|
|
46
|
+
- `--no-nexus`: turn the plugin off for one run.
|
|
47
|
+
|
|
48
|
+
Build, commit and the CI job page are filled automatically on GitHub Actions, GitLab,
|
|
49
|
+
Azure Pipelines, Bitbucket Pipelines and Jenkins (the job page also on CircleCI). A job page
|
|
50
|
+
that is not https is never sent.
|
|
51
|
+
|
|
52
|
+
## Name your tests with the Automation Key
|
|
53
|
+
|
|
54
|
+
Put the Test Case's Automation Key in the test name. Python names cannot contain `-`, so
|
|
55
|
+
use `_`; Nexus reads `NEX_11` as `NEX-11`:
|
|
56
|
+
|
|
57
|
+
```python
|
|
58
|
+
def test_NEX_11_valid_user_signs_in(driver):
|
|
59
|
+
...
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Results whose name carries no known key are listed as *unmatched* in the delivery log.
|
|
63
|
+
|
|
64
|
+
## Example
|
|
65
|
+
|
|
66
|
+
```sh
|
|
67
|
+
export NEXUS_URL=... # from your CI secret store
|
|
68
|
+
export NEXUS_API_KEY=...
|
|
69
|
+
pytest --nexus-project NEX --nexus-environment staging
|
|
70
|
+
```
|
|
71
|
+
|
|
72
|
+
If you already pass `--junitxml=path.xml`, that file is the one sent. Otherwise the
|
|
73
|
+
plugin writes a temporary report and removes it afterwards. With pytest-xdist, only the
|
|
74
|
+
controller sends.
|
|
75
|
+
|
|
76
|
+
Output at the end of the run:
|
|
77
|
+
|
|
78
|
+
```
|
|
79
|
+
nexus: sent results to project NEX at <your-site>.atlassian-dev.net (HTTP 202). See Project Settings > CI for the delivery log.
|
|
80
|
+
```
|
|
81
|
+
|
|
82
|
+
Nexus answers `202` with `{"status":"accepted"}` only after it has processed every
|
|
83
|
+
result of the report (SEC27-H9, 28/09/2026: nothing is left running in the background
|
|
84
|
+
behind an "accepted"); the per-test outcome (matched, unmatched, skipped) is in the delivery
|
|
85
|
+
log on the CI page. A `{"status":"partial"}` answer means Nexus holds the report and what it
|
|
86
|
+
recorded so far and finishes the rest itself; the reporter sends it again to confirm.
|
|
87
|
+
The API key is never printed. Reports over 5 MB or 500 results must be split.
|
|
88
|
+
A report that reaches Nexus's time limit part-way (a 202 whose body says "partial", or a
|
|
89
|
+
500), a 502/503/504 or a network error is sent again, up to 8 sends, until Nexus has
|
|
90
|
+
recorded all of it; a resend never records a result twice (0.2.0, 27/09/2026). Since 0.2.3
|
|
91
|
+
every send of one report carries the same `Idempotency-Key` header, so Nexus treats a late
|
|
92
|
+
resend as the same delivery, never as a new run.
|
|
93
|
+
|
|
94
|
+
## Develop
|
|
95
|
+
|
|
96
|
+
```sh
|
|
97
|
+
python -m pytest # from this folder; uses a local fake Nexus, no network
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
## Licence
|
|
101
|
+
|
|
102
|
+
MIT. © 2026 Resync Consulting Limited. See `LICENSE`.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling>=1.18"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "pytest-nexus"
|
|
7
|
+
version = "0.2.4"
|
|
8
|
+
description = "pytest plugin that sends each run's JUnit XML results to Nexus for Jira."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.8"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
authors = [{ name = "Resync Consulting Limited" }]
|
|
13
|
+
dependencies = ["pytest>=7.0"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Framework :: Pytest",
|
|
16
|
+
"Programming Language :: Python :: 3",
|
|
17
|
+
"License :: OSI Approved :: MIT License",
|
|
18
|
+
"Operating System :: OS Independent",
|
|
19
|
+
"Topic :: Software Development :: Testing",
|
|
20
|
+
]
|
|
21
|
+
keywords = ["pytest", "jira", "nexus", "test management", "junit"]
|
|
22
|
+
|
|
23
|
+
[project.urls]
|
|
24
|
+
Homepage = "https://nexus.resync.nz"
|
|
25
|
+
Documentation = "https://nexus.resync.nz/docs/automation/"
|
|
26
|
+
|
|
27
|
+
[project.entry-points.pytest11]
|
|
28
|
+
nexus = "pytest_nexus.plugin"
|
|
29
|
+
|
|
30
|
+
[tool.hatch.build.targets.wheel]
|
|
31
|
+
packages = ["pytest_nexus"]
|
|
32
|
+
|
|
33
|
+
[tool.pytest.ini_options]
|
|
34
|
+
testpaths = ["tests"]
|
|
35
|
+
pythonpath = ["."]
|
|
36
|
+
addopts = "-p pytester"
|
|
@@ -0,0 +1,484 @@
|
|
|
1
|
+
"""pytest plugin: send the session's JUnit XML to Nexus for Jira when the run ends.
|
|
2
|
+
|
|
3
|
+
Enabled when a Nexus URL is configured (``--nexus-url`` or ``NEXUS_URL``); otherwise it
|
|
4
|
+
does nothing. If ``--junitxml`` is not given, the plugin writes its own report to a
|
|
5
|
+
temporary file. Upload problems only warn unless ``--nexus-fail-on-upload-error`` is set.
|
|
6
|
+
The API key is read from ``NEXUS_API_KEY`` (or ``--nexus-api-key``) and never printed.
|
|
7
|
+
Standard library only.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import base64
|
|
13
|
+
import json
|
|
14
|
+
import os
|
|
15
|
+
import re
|
|
16
|
+
import tempfile
|
|
17
|
+
import time
|
|
18
|
+
import urllib.error
|
|
19
|
+
import urllib.parse
|
|
20
|
+
import urllib.request
|
|
21
|
+
import uuid
|
|
22
|
+
from dataclasses import dataclass, field
|
|
23
|
+
from typing import Callable, Mapping, Optional
|
|
24
|
+
|
|
25
|
+
import pytest
|
|
26
|
+
|
|
27
|
+
__all__ = [
|
|
28
|
+
"NexusConfig",
|
|
29
|
+
"resolve_config",
|
|
30
|
+
"config_problem",
|
|
31
|
+
"transport_problem",
|
|
32
|
+
"build_request_url",
|
|
33
|
+
"transport_body",
|
|
34
|
+
"post_results",
|
|
35
|
+
"ci_build_url",
|
|
36
|
+
"new_idempotency_key",
|
|
37
|
+
]
|
|
38
|
+
|
|
39
|
+
VERSION = "0.2.4"
|
|
40
|
+
# 0.2.4 (10/10/2026): first PyPI release (`pip install pytest-nexus`); MIT licence. No behaviour change.
|
|
41
|
+
# 0.2.3 (28/09/2026, parity with the other reporters):
|
|
42
|
+
# - every upload carries an ``Idempotency-Key`` (docs/api-contracts.md section 1 "Durable
|
|
43
|
+
# deliveries"), the same on every resend of that report (continuations, retries and the
|
|
44
|
+
# raw-XML fallback), so Nexus treats all the sends as one delivery however late the last
|
|
45
|
+
# one arrives. ``--nexus-idempotency-key`` / NEXUS_IDEMPOTENCY_KEY sets it (16-128 of
|
|
46
|
+
# A-Z a-z 0-9 . _ : -, as for the nexus-upload CLI); otherwise ``pytest-nexus-<uuid>``,
|
|
47
|
+
# new for each run.
|
|
48
|
+
# - ``&buildUrl=``: the CI job's page, shown on each run as "Open CI job"
|
|
49
|
+
# (``--nexus-build-url`` / NEXUS_BUILD_URL, else the CI system's own variables: GitHub
|
|
50
|
+
# Actions, GitLab, Azure Pipelines, Jenkins, Bitbucket Pipelines, CircleCI). https only.
|
|
51
|
+
# 0.2.2 (28/09/2026, automation e2e F1): the JUnit XML is sent inside a JSON envelope,
|
|
52
|
+
# {"format": "junit-xml", "encoding": "base64", "content": ...} (or "encoding": "text" when
|
|
53
|
+
# base64 would pass the 5 MB cap), because raw line breaks in an XML body did not reach
|
|
54
|
+
# Nexus and stack traces lost their lines. Against an older Nexus (400 to the envelope,
|
|
55
|
+
# nothing recorded) the raw XML is sent once instead.
|
|
56
|
+
# 0.2.1 (28/09/2026, SEC27-M4 transport): the Nexus URL must be https; plain http is
|
|
57
|
+
# refused before the key or the report is sent, except to localhost/127.0.0.1/::1 when
|
|
58
|
+
# NEXUS_ALLOW_INSECURE_LOCALHOST=1 (local tests only). Redirects are never followed, so
|
|
59
|
+
# the bearer key is never re-sent to another address.
|
|
60
|
+
# 0.2.0 (27/09/2026, ingestion idempotency): a report that reaches Nexus's time limit
|
|
61
|
+
# part-way is sent again until Nexus has recorded all of it. Nexus remembers what each
|
|
62
|
+
# upload already recorded, so a resend never records a result twice; a 202 whose body
|
|
63
|
+
# says "partial", a 500, a 502/503/504 and a network error are all resent.
|
|
64
|
+
MAX_ATTEMPTS = 8
|
|
65
|
+
MAX_RETRY_DELAY = 15.0
|
|
66
|
+
_RESEND_STATUSES = (500, 502, 503, 504)
|
|
67
|
+
MAX_BODY_BYTES = 5 * 1024 * 1024
|
|
68
|
+
PROJECT_KEY = re.compile(r"^[A-Z][A-Z0-9_]+$")
|
|
69
|
+
ISSUE_KEY = re.compile(r"^[A-Z][A-Z0-9_]+-\d+$")
|
|
70
|
+
INSECURE_LOCALHOST_ENV = "NEXUS_ALLOW_INSECURE_LOCALHOST"
|
|
71
|
+
_LOOPBACK_HOSTS = ("localhost", "127.0.0.1", "::1")
|
|
72
|
+
# Nexus's rule for the Idempotency-Key header (docs/api-contracts.md section 1 "Replay protection").
|
|
73
|
+
IDEMPOTENCY_KEY = re.compile(r"^[A-Za-z0-9._:-]{16,128}$")
|
|
74
|
+
MAX_BUILD_URL_CHARS = 500
|
|
75
|
+
|
|
76
|
+
# option dest -> environment variable
|
|
77
|
+
_ENV = {
|
|
78
|
+
"url": "NEXUS_URL",
|
|
79
|
+
"api_key": "NEXUS_API_KEY",
|
|
80
|
+
"project": "NEXUS_PROJECT",
|
|
81
|
+
"environment": "NEXUS_ENVIRONMENT",
|
|
82
|
+
"test_set": "NEXUS_TEST_SET",
|
|
83
|
+
"build": "NEXUS_BUILD",
|
|
84
|
+
"commit": "NEXUS_COMMIT",
|
|
85
|
+
"component": "NEXUS_COMPONENT",
|
|
86
|
+
"build_url": "NEXUS_BUILD_URL",
|
|
87
|
+
"idempotency_key": "NEXUS_IDEMPOTENCY_KEY",
|
|
88
|
+
}
|
|
89
|
+
_CI_BUILD = ("GITHUB_RUN_ID", "CI_PIPELINE_IID", "BUILD_BUILDNUMBER", "BITBUCKET_BUILD_NUMBER", "BUILD_NUMBER")
|
|
90
|
+
_CI_COMMIT = ("GITHUB_SHA", "CI_COMMIT_SHA", "BUILD_SOURCEVERSION", "BITBUCKET_COMMIT", "GIT_COMMIT")
|
|
91
|
+
|
|
92
|
+
_STATUS_HINT = {
|
|
93
|
+
400: "the report was not accepted (or the test set names nothing in this project)",
|
|
94
|
+
401: 'unauthorized: wrong, revoked or other-project key, or the key lacks the "ingest" scope',
|
|
95
|
+
413: "the report is over 5 MB or 500 results",
|
|
96
|
+
500: "Nexus stopped before the end (time limit) or had an unexpected error; see the delivery log in Project Settings > CI",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
_STATE_KEY = pytest.StashKey["_NexusState"]()
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
@dataclass
|
|
103
|
+
class NexusConfig:
|
|
104
|
+
url: Optional[str] = None
|
|
105
|
+
api_key: Optional[str] = None
|
|
106
|
+
project: Optional[str] = None
|
|
107
|
+
environment: Optional[str] = None
|
|
108
|
+
test_set: Optional[str] = None
|
|
109
|
+
build: Optional[str] = None
|
|
110
|
+
commit: Optional[str] = None
|
|
111
|
+
component: Optional[str] = None
|
|
112
|
+
build_url: Optional[str] = None
|
|
113
|
+
idempotency_key: Optional[str] = None
|
|
114
|
+
fail_on_upload_error: bool = False
|
|
115
|
+
timeout: float = 60.0
|
|
116
|
+
# Test switch only: allow plain http to this machine (NEXUS_ALLOW_INSECURE_LOCALHOST=1).
|
|
117
|
+
allow_insecure_localhost: bool = False
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
@dataclass
|
|
121
|
+
class _NexusState:
|
|
122
|
+
config: NexusConfig
|
|
123
|
+
xml_path: str
|
|
124
|
+
temp_dir: Optional[str] = None
|
|
125
|
+
lines: list = field(default_factory=list)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _clean(value: object) -> Optional[str]:
|
|
129
|
+
if value is None:
|
|
130
|
+
return None
|
|
131
|
+
text = str(value).strip()
|
|
132
|
+
return text or None
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _first(env: Mapping[str, str], names) -> Optional[str]:
|
|
136
|
+
for name in names:
|
|
137
|
+
value = _clean(env.get(name))
|
|
138
|
+
if value:
|
|
139
|
+
return value
|
|
140
|
+
return None
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _https_url(value: Optional[str]) -> Optional[str]:
|
|
144
|
+
"""``value`` when it is a plain https URL of at most 500 characters, else None."""
|
|
145
|
+
if not value or len(value) > MAX_BUILD_URL_CHARS or any(ch.isspace() for ch in value):
|
|
146
|
+
return None
|
|
147
|
+
try:
|
|
148
|
+
parsed = urllib.parse.urlsplit(value)
|
|
149
|
+
hostname = parsed.hostname
|
|
150
|
+
except ValueError:
|
|
151
|
+
return None
|
|
152
|
+
if parsed.scheme != "https" or not hostname or parsed.username is not None or parsed.password is not None:
|
|
153
|
+
return None
|
|
154
|
+
return value
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def ci_build_url(env: Mapping[str, str]) -> Optional[str]:
|
|
158
|
+
"""The CI job's own page from the CI system's standard variables (0.2.3, ``&buildUrl=``),
|
|
159
|
+
https only, else None. Same order as the nexus-upload CLI and the Playwright reporter:
|
|
160
|
+
GitHub Actions, GitLab, Azure Pipelines, Jenkins, Bitbucket Pipelines, CircleCI."""
|
|
161
|
+
|
|
162
|
+
def v(name: str) -> Optional[str]:
|
|
163
|
+
return _clean(env.get(name))
|
|
164
|
+
|
|
165
|
+
candidates = []
|
|
166
|
+
if v("GITHUB_SERVER_URL") and v("GITHUB_REPOSITORY") and v("GITHUB_RUN_ID"):
|
|
167
|
+
server = v("GITHUB_SERVER_URL").rstrip("/")
|
|
168
|
+
candidates.append(f"{server}/{v('GITHUB_REPOSITORY')}/actions/runs/{v('GITHUB_RUN_ID')}")
|
|
169
|
+
candidates.append(v("CI_JOB_URL"))
|
|
170
|
+
if v("SYSTEM_TEAMFOUNDATIONCOLLECTIONURI") and v("SYSTEM_TEAMPROJECT") and v("BUILD_BUILDID"):
|
|
171
|
+
collection = v("SYSTEM_TEAMFOUNDATIONCOLLECTIONURI").rstrip("/")
|
|
172
|
+
project = urllib.parse.quote(v("SYSTEM_TEAMPROJECT"), safe="!*'()")
|
|
173
|
+
build_id = urllib.parse.quote(v("BUILD_BUILDID"), safe="!*'()")
|
|
174
|
+
candidates.append(f"{collection}/{project}/_build/results?buildId={build_id}")
|
|
175
|
+
candidates.append(v("BUILD_URL"))
|
|
176
|
+
if v("BITBUCKET_REPO_FULL_NAME") and v("BITBUCKET_BUILD_NUMBER"):
|
|
177
|
+
candidates.append(f"https://bitbucket.org/{v('BITBUCKET_REPO_FULL_NAME')}/pipelines/results/{v('BITBUCKET_BUILD_NUMBER')}")
|
|
178
|
+
candidates.append(v("CIRCLE_BUILD_URL"))
|
|
179
|
+
for candidate in candidates:
|
|
180
|
+
url = _https_url(candidate)
|
|
181
|
+
if url:
|
|
182
|
+
return url
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def new_idempotency_key() -> str:
|
|
187
|
+
"""A new retry key for one report upload (0.2.3)."""
|
|
188
|
+
return f"pytest-nexus-{uuid.uuid4()}"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def resolve_config(options: Mapping[str, object], env: Mapping[str, str]) -> NexusConfig:
|
|
192
|
+
"""CLI options over NEXUS_* env vars over the CI system's own build/commit/job-page variables."""
|
|
193
|
+
values = {name: _clean(options.get(name)) or _clean(env.get(var)) for name, var in _ENV.items()}
|
|
194
|
+
values["build"] = values["build"] or _first(env, _CI_BUILD)
|
|
195
|
+
values["commit"] = values["commit"] or _first(env, _CI_COMMIT)
|
|
196
|
+
# As in the Playwright reporter: a given value that is not https is dropped and the CI
|
|
197
|
+
# system's own job page is used instead.
|
|
198
|
+
values["build_url"] = _https_url(values["build_url"]) or ci_build_url(env)
|
|
199
|
+
return NexusConfig(
|
|
200
|
+
**values,
|
|
201
|
+
fail_on_upload_error=bool(options.get("fail_on_upload_error")),
|
|
202
|
+
allow_insecure_localhost=env.get(INSECURE_LOCALHOST_ENV) == "1",
|
|
203
|
+
)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def transport_problem(cfg: NexusConfig) -> Optional[str]:
|
|
207
|
+
"""Why the key must not be sent to ``cfg.url``, or None: https always; http only to
|
|
208
|
+
localhost/127.0.0.1/::1 and only with NEXUS_ALLOW_INSECURE_LOCALHOST=1 (tests)."""
|
|
209
|
+
parsed = urllib.parse.urlparse(cfg.url or "")
|
|
210
|
+
if not parsed.netloc:
|
|
211
|
+
return "NEXUS_URL is not a valid https URL"
|
|
212
|
+
if parsed.scheme == "https":
|
|
213
|
+
return None
|
|
214
|
+
if parsed.scheme == "http" and cfg.allow_insecure_localhost and (parsed.hostname or "").lower() in _LOOPBACK_HOSTS:
|
|
215
|
+
return None
|
|
216
|
+
return "NEXUS_URL is not a valid https URL (plain http would expose the API key)"
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
class _NoRedirect(urllib.request.HTTPRedirectHandler):
|
|
220
|
+
"""Never follow a redirect: urllib would copy the Authorization header onto it."""
|
|
221
|
+
|
|
222
|
+
def redirect_request(self, req, fp, code, msg, headers, newurl): # noqa: D401
|
|
223
|
+
return None
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
_OPENER = urllib.request.build_opener(_NoRedirect)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _open(request, timeout):
|
|
230
|
+
return _OPENER.open(request, timeout=timeout)
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def config_problem(cfg: NexusConfig) -> Optional[str]:
|
|
234
|
+
if not cfg.url:
|
|
235
|
+
return "NEXUS_URL is not set"
|
|
236
|
+
problem = transport_problem(cfg)
|
|
237
|
+
if problem:
|
|
238
|
+
return problem
|
|
239
|
+
if not cfg.api_key:
|
|
240
|
+
return "NEXUS_API_KEY is not set"
|
|
241
|
+
if not cfg.project:
|
|
242
|
+
return "NEXUS_PROJECT is not set"
|
|
243
|
+
if not PROJECT_KEY.match(cfg.project):
|
|
244
|
+
return f'"{cfg.project}" is not a Jira project key'
|
|
245
|
+
if cfg.test_set and not ISSUE_KEY.match(cfg.test_set):
|
|
246
|
+
return f'"{cfg.test_set}" is not an issue key (e.g. NEX-25)'
|
|
247
|
+
if cfg.idempotency_key and not IDEMPOTENCY_KEY.match(cfg.idempotency_key):
|
|
248
|
+
return 'the idempotency key takes 16-128 letters, digits, ".", "_", ":" or "-"'
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def build_request_url(cfg: NexusConfig) -> str:
|
|
253
|
+
parts = urllib.parse.urlsplit(cfg.url)
|
|
254
|
+
query = urllib.parse.parse_qsl(parts.query, keep_blank_values=True)
|
|
255
|
+
params = {
|
|
256
|
+
"project": cfg.project,
|
|
257
|
+
"format": "junit-xml",
|
|
258
|
+
"environment": cfg.environment,
|
|
259
|
+
"testSet": cfg.test_set,
|
|
260
|
+
"build": cfg.build,
|
|
261
|
+
"commit": cfg.commit,
|
|
262
|
+
"component": cfg.component,
|
|
263
|
+
"buildUrl": cfg.build_url,
|
|
264
|
+
}
|
|
265
|
+
names = set(params)
|
|
266
|
+
query = [(k, v) for k, v in query if k not in names] + [(k, v) for k, v in params.items() if v]
|
|
267
|
+
return urllib.parse.urlunsplit(parts._replace(query=urllib.parse.urlencode(query)))
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _redact(text: str, key: Optional[str]) -> str:
|
|
271
|
+
return text.replace(key, "***") if key else text
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
def _is_partial(body: bytes) -> bool:
|
|
275
|
+
"""A 2xx body saying {"status": "partial"}: Nexus recorded part of the upload."""
|
|
276
|
+
try:
|
|
277
|
+
parsed = json.loads(body.decode("utf-8"))
|
|
278
|
+
except (ValueError, UnicodeDecodeError):
|
|
279
|
+
return False
|
|
280
|
+
return isinstance(parsed, dict) and parsed.get("status") == "partial"
|
|
281
|
+
|
|
282
|
+
|
|
283
|
+
def transport_body(body: bytes) -> tuple:
|
|
284
|
+
"""(data, content_type, encoding) for one JUnit XML report (0.2.2, docs/api-contracts.md
|
|
285
|
+
section 1 "Transport: the upload envelope"): base64-wrapped in JSON so its line breaks
|
|
286
|
+
survive; the plain JSON-string form when base64 would pass the 5 MB cap; the raw XML
|
|
287
|
+
only when even that would."""
|
|
288
|
+
for encoding in ("base64", "text"):
|
|
289
|
+
if encoding == "base64":
|
|
290
|
+
content = base64.b64encode(body).decode("ascii")
|
|
291
|
+
else:
|
|
292
|
+
content = body.decode("utf-8", errors="replace")
|
|
293
|
+
wrapped = json.dumps({"format": "junit-xml", "encoding": encoding, "content": content}).encode("utf-8")
|
|
294
|
+
if len(wrapped) <= MAX_BODY_BYTES:
|
|
295
|
+
return wrapped, "application/json", encoding
|
|
296
|
+
return body, "application/xml", "none"
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
def post_results(
|
|
300
|
+
cfg: NexusConfig,
|
|
301
|
+
body: bytes,
|
|
302
|
+
opener: Callable = _open,
|
|
303
|
+
retry_delay: float = 2.0,
|
|
304
|
+
max_attempts: int = MAX_ATTEMPTS,
|
|
305
|
+
sleep: Callable = time.sleep,
|
|
306
|
+
) -> tuple:
|
|
307
|
+
"""POST the JUnit XML. Returns (ok, message). Sends the same upload again until Nexus
|
|
308
|
+
has recorded all of it: after a 202 "partial", a 500 (time limit reached part-way), a
|
|
309
|
+
502/503/504 or a network error, up to ``max_attempts`` sends with a pause doubling
|
|
310
|
+
from ``retry_delay`` to 15 s. Safe: Nexus never records a result of one upload twice."""
|
|
311
|
+
problem = transport_problem(cfg)
|
|
312
|
+
if problem:
|
|
313
|
+
return False, problem
|
|
314
|
+
last = ""
|
|
315
|
+
data, content_type, encoding = transport_body(body)
|
|
316
|
+
# 0.2.2: a Nexus older than 28/09/2026 does not know the envelope and answers 400
|
|
317
|
+
# (nothing recorded); the raw XML is then sent once instead.
|
|
318
|
+
fell_back = encoding == "none"
|
|
319
|
+
# 0.2.3: one key for this upload, repeated on every send below.
|
|
320
|
+
idempotency_key = cfg.idempotency_key or new_idempotency_key()
|
|
321
|
+
attempt = 0
|
|
322
|
+
while attempt < max_attempts:
|
|
323
|
+
attempt += 1
|
|
324
|
+
request = urllib.request.Request(
|
|
325
|
+
build_request_url(cfg),
|
|
326
|
+
data=data,
|
|
327
|
+
method="POST",
|
|
328
|
+
headers={
|
|
329
|
+
"Authorization": f"Bearer {cfg.api_key}",
|
|
330
|
+
"Content-Type": content_type,
|
|
331
|
+
"Idempotency-Key": idempotency_key,
|
|
332
|
+
"User-Agent": f"pytest-nexus/{VERSION}",
|
|
333
|
+
},
|
|
334
|
+
)
|
|
335
|
+
try:
|
|
336
|
+
with opener(request, timeout=cfg.timeout) as response:
|
|
337
|
+
status = response.status
|
|
338
|
+
text = response.read()
|
|
339
|
+
if not _is_partial(text or b""):
|
|
340
|
+
return True, f"HTTP {status}"
|
|
341
|
+
last = f"HTTP {status} (Nexus recorded part of the report)"
|
|
342
|
+
except urllib.error.HTTPError as err:
|
|
343
|
+
status = err.code
|
|
344
|
+
if 300 <= status < 400:
|
|
345
|
+
return False, (
|
|
346
|
+
f"HTTP {status} (a redirect, not followed so the API key is never sent elsewhere; "
|
|
347
|
+
"copy the Results URL again from Project Settings > CI)"
|
|
348
|
+
)
|
|
349
|
+
hint = _STATUS_HINT.get(status)
|
|
350
|
+
last = f"HTTP {status}" + (f" ({hint})" if hint else "")
|
|
351
|
+
if status == 400 and not fell_back:
|
|
352
|
+
fell_back = True
|
|
353
|
+
data, content_type = body, "application/xml"
|
|
354
|
+
attempt -= 1
|
|
355
|
+
continue
|
|
356
|
+
if status not in _RESEND_STATUSES:
|
|
357
|
+
return False, last
|
|
358
|
+
except (urllib.error.URLError, OSError) as err:
|
|
359
|
+
reason = getattr(err, "reason", err)
|
|
360
|
+
last = f"network error: {_redact(str(reason), cfg.api_key)}"
|
|
361
|
+
if attempt < max_attempts:
|
|
362
|
+
sleep(min(retry_delay * 2 ** (attempt - 1), MAX_RETRY_DELAY))
|
|
363
|
+
return False, f"{last} (gave up after {max_attempts} sends)"
|
|
364
|
+
|
|
365
|
+
|
|
366
|
+
# --------------------------------------------------------------------------- hooks
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def pytest_addoption(parser):
|
|
370
|
+
group = parser.getgroup("nexus", "send results to Nexus for Jira")
|
|
371
|
+
group.addoption("--nexus-url", dest="nexus_url", help="Results URL from Project Settings > CI (default: NEXUS_URL)")
|
|
372
|
+
group.addoption("--nexus-api-key", dest="nexus_api_key", help="API key with the ingest scope; prefer the NEXUS_API_KEY env var")
|
|
373
|
+
group.addoption("--nexus-project", dest="nexus_project", help="Jira project key (default: NEXUS_PROJECT)")
|
|
374
|
+
group.addoption("--nexus-environment", dest="nexus_environment", help="Environment label (default: NEXUS_ENVIRONMENT)")
|
|
375
|
+
group.addoption("--nexus-test-set", dest="nexus_test_set", help="Test Set (cycle) key, e.g. NEX-25 (default: NEXUS_TEST_SET)")
|
|
376
|
+
group.addoption("--nexus-build", dest="nexus_build", help="Build id (default: NEXUS_BUILD, else the CI build number)")
|
|
377
|
+
group.addoption("--nexus-commit", dest="nexus_commit", help="Commit (default: NEXUS_COMMIT, else the CI commit SHA)")
|
|
378
|
+
group.addoption("--nexus-component", dest="nexus_component", help="Component label (default: NEXUS_COMPONENT)")
|
|
379
|
+
group.addoption(
|
|
380
|
+
"--nexus-build-url",
|
|
381
|
+
dest="nexus_build_url",
|
|
382
|
+
help="CI job page, https (default: NEXUS_BUILD_URL, else the CI system's own variables)",
|
|
383
|
+
)
|
|
384
|
+
group.addoption(
|
|
385
|
+
"--nexus-idempotency-key",
|
|
386
|
+
dest="nexus_idempotency_key",
|
|
387
|
+
help="Retry key sent on every send of the report, 16-128 of A-Z a-z 0-9 . _ : - (default: NEXUS_IDEMPOTENCY_KEY, else a new one per run)",
|
|
388
|
+
)
|
|
389
|
+
group.addoption(
|
|
390
|
+
"--nexus-fail-on-upload-error",
|
|
391
|
+
dest="nexus_fail_on_upload_error",
|
|
392
|
+
action="store_true",
|
|
393
|
+
default=False,
|
|
394
|
+
help="Fail the pytest run when the upload fails (default: warn only)",
|
|
395
|
+
)
|
|
396
|
+
group.addoption("--no-nexus", dest="nexus_disabled", action="store_true", default=False, help="Do not send results to Nexus")
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _options(config) -> dict:
|
|
400
|
+
return {
|
|
401
|
+
name: config.getoption(f"nexus_{name}")
|
|
402
|
+
for name in (
|
|
403
|
+
"url",
|
|
404
|
+
"api_key",
|
|
405
|
+
"project",
|
|
406
|
+
"environment",
|
|
407
|
+
"test_set",
|
|
408
|
+
"build",
|
|
409
|
+
"commit",
|
|
410
|
+
"component",
|
|
411
|
+
"build_url",
|
|
412
|
+
"idempotency_key",
|
|
413
|
+
"fail_on_upload_error",
|
|
414
|
+
)
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
|
|
418
|
+
@pytest.hookimpl(tryfirst=True)
|
|
419
|
+
def pytest_configure(config):
|
|
420
|
+
"""Runs before the junitxml plugin's own configure, so a missing --junitxml can be
|
|
421
|
+
pointed at a temporary file."""
|
|
422
|
+
if config.getoption("nexus_disabled") or hasattr(config, "workerinput"):
|
|
423
|
+
return
|
|
424
|
+
cfg = resolve_config(_options(config), os.environ)
|
|
425
|
+
if not cfg.url:
|
|
426
|
+
return # not configured: stay out of the way
|
|
427
|
+
temp_dir = None
|
|
428
|
+
xml_path = config.option.xmlpath
|
|
429
|
+
if not xml_path:
|
|
430
|
+
temp_dir = tempfile.mkdtemp(prefix="pytest-nexus-")
|
|
431
|
+
xml_path = os.path.join(temp_dir, "results.xml")
|
|
432
|
+
config.option.xmlpath = xml_path
|
|
433
|
+
config.stash[_STATE_KEY] = _NexusState(config=cfg, xml_path=os.path.abspath(xml_path), temp_dir=temp_dir)
|
|
434
|
+
|
|
435
|
+
|
|
436
|
+
@pytest.hookimpl(trylast=True)
|
|
437
|
+
def pytest_sessionfinish(session, exitstatus):
|
|
438
|
+
"""Runs after the junitxml plugin has written its file."""
|
|
439
|
+
config = session.config
|
|
440
|
+
state = config.stash.get(_STATE_KEY, None)
|
|
441
|
+
if state is None:
|
|
442
|
+
return
|
|
443
|
+
cfg = state.config
|
|
444
|
+
ok, message = _send(state)
|
|
445
|
+
say = f"nexus: {_redact(message, cfg.api_key)}"
|
|
446
|
+
state.lines.append(say)
|
|
447
|
+
if not ok and cfg.fail_on_upload_error and session.exitstatus == 0:
|
|
448
|
+
session.exitstatus = pytest.ExitCode.TESTS_FAILED
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _send(state: _NexusState) -> tuple:
|
|
452
|
+
cfg = state.config
|
|
453
|
+
problem = config_problem(cfg)
|
|
454
|
+
if problem:
|
|
455
|
+
return False, f"results not sent: {problem}."
|
|
456
|
+
try:
|
|
457
|
+
size = os.path.getsize(state.xml_path)
|
|
458
|
+
except OSError:
|
|
459
|
+
return False, "results not sent: no JUnit XML was written."
|
|
460
|
+
if size > MAX_BODY_BYTES:
|
|
461
|
+
return False, "results not sent: the JUnit XML is over 5 MB. Split the run."
|
|
462
|
+
with open(state.xml_path, "rb") as handle:
|
|
463
|
+
body = handle.read()
|
|
464
|
+
host = urllib.parse.urlparse(cfg.url).netloc
|
|
465
|
+
ok, detail = post_results(cfg, body)
|
|
466
|
+
if ok:
|
|
467
|
+
return True, f"sent results to project {cfg.project} at {host} ({detail}). See Project Settings > CI for the delivery log."
|
|
468
|
+
return False, f"results not sent to {host}: {detail}"
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def pytest_terminal_summary(terminalreporter, exitstatus, config):
|
|
472
|
+
state = config.stash.get(_STATE_KEY, None)
|
|
473
|
+
if state is None:
|
|
474
|
+
return
|
|
475
|
+
for line in state.lines:
|
|
476
|
+
terminalreporter.write_line(line)
|
|
477
|
+
|
|
478
|
+
|
|
479
|
+
def pytest_unconfigure(config):
|
|
480
|
+
state = config.stash.get(_STATE_KEY, None)
|
|
481
|
+
if state is not None and state.temp_dir:
|
|
482
|
+
import shutil
|
|
483
|
+
|
|
484
|
+
shutil.rmtree(state.temp_dir, ignore_errors=True)
|
|
@@ -0,0 +1,429 @@
|
|
|
1
|
+
"""Tests for pytest-nexus. Run from this folder: python -m pytest"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import base64
|
|
6
|
+
import http.server
|
|
7
|
+
import json
|
|
8
|
+
import os
|
|
9
|
+
import pathlib
|
|
10
|
+
import threading
|
|
11
|
+
import urllib.error
|
|
12
|
+
import urllib.parse
|
|
13
|
+
from importlib import metadata
|
|
14
|
+
|
|
15
|
+
import pytest
|
|
16
|
+
|
|
17
|
+
from pytest_nexus.plugin import IDEMPOTENCY_KEY, MAX_BODY_BYTES, NexusConfig, build_request_url, ci_build_url, config_problem, new_idempotency_key, post_results, resolve_config, transport_body
|
|
18
|
+
|
|
19
|
+
KEY = "nxk_never_print_this_555"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def unwrap(body: bytes) -> bytes:
|
|
23
|
+
"""What Nexus unwraps from the 0.2.2 JSON envelope."""
|
|
24
|
+
env = json.loads(body.decode("utf-8"))
|
|
25
|
+
assert env["format"] == "junit-xml"
|
|
26
|
+
return base64.b64decode(env["content"]) if env["encoding"] == "base64" else env["content"].encode("utf-8")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _plugin_installed() -> bool:
|
|
30
|
+
try:
|
|
31
|
+
eps = metadata.entry_points()
|
|
32
|
+
group = eps.select(group="pytest11") if hasattr(eps, "select") else eps.get("pytest11", [])
|
|
33
|
+
return any(ep.value == "pytest_nexus.plugin" for ep in group)
|
|
34
|
+
except Exception:
|
|
35
|
+
return False
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
# When the package is pip-installed the entry point loads it; otherwise load it by name.
|
|
39
|
+
PLUGIN_ARGS = [] if _plugin_installed() else ["-p", "pytest_nexus.plugin"]
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class FakeNexus:
|
|
43
|
+
"""A local stand-in for the Nexus results URL that records each POST."""
|
|
44
|
+
|
|
45
|
+
def __init__(self, statuses=(202,), bodies=()):
|
|
46
|
+
self.statuses = list(statuses)
|
|
47
|
+
self.bodies = list(bodies)
|
|
48
|
+
self.requests = []
|
|
49
|
+
outer = self
|
|
50
|
+
|
|
51
|
+
class Handler(http.server.BaseHTTPRequestHandler):
|
|
52
|
+
def do_POST(self): # noqa: N802
|
|
53
|
+
length = int(self.headers.get("Content-Length", 0))
|
|
54
|
+
body = self.rfile.read(length)
|
|
55
|
+
outer.requests.append({"path": self.path, "headers": dict(self.headers), "body": body})
|
|
56
|
+
status = outer.statuses[min(len(outer.requests) - 1, len(outer.statuses) - 1)]
|
|
57
|
+
reply = outer.bodies[min(len(outer.requests) - 1, len(outer.bodies) - 1)] if outer.bodies else b""
|
|
58
|
+
self.send_response(status)
|
|
59
|
+
self.send_header("Content-Length", str(len(reply)))
|
|
60
|
+
self.end_headers()
|
|
61
|
+
self.wfile.write(reply)
|
|
62
|
+
|
|
63
|
+
def log_message(self, *args):
|
|
64
|
+
pass
|
|
65
|
+
|
|
66
|
+
self.server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
67
|
+
self.url = f"http://127.0.0.1:{self.server.server_address[1]}/x1/secret-path"
|
|
68
|
+
threading.Thread(target=self.server.serve_forever, daemon=True).start()
|
|
69
|
+
|
|
70
|
+
def query(self, i=0):
|
|
71
|
+
return dict(urllib.parse.parse_qsl(urllib.parse.urlsplit(self.requests[i]["path"]).query))
|
|
72
|
+
|
|
73
|
+
def close(self):
|
|
74
|
+
self.server.shutdown()
|
|
75
|
+
self.server.server_close()
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@pytest.fixture
|
|
79
|
+
def nexus():
|
|
80
|
+
server = FakeNexus()
|
|
81
|
+
yield server
|
|
82
|
+
server.close()
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
# The CI job-page variables ci_build_url reads (0.2.3): cleared so a run inside CI tests the same.
|
|
86
|
+
_CI_URL_VARS = ("GITHUB_SERVER_URL", "GITHUB_REPOSITORY", "CI_JOB_URL", "SYSTEM_TEAMFOUNDATIONCOLLECTIONURI", "SYSTEM_TEAMPROJECT", "BUILD_BUILDID", "BUILD_URL", "BITBUCKET_REPO_FULL_NAME", "CIRCLE_BUILD_URL")
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
@pytest.fixture
|
|
90
|
+
def clean_env(monkeypatch):
|
|
91
|
+
for name in list(os.environ):
|
|
92
|
+
if name.startswith("NEXUS_") or name in ("GITHUB_RUN_ID", "GITHUB_SHA", "CI_PIPELINE_IID", "CI_COMMIT_SHA", "BUILD_NUMBER", "GIT_COMMIT", "BUILD_BUILDNUMBER", "BUILD_SOURCEVERSION", "BITBUCKET_BUILD_NUMBER", "BITBUCKET_COMMIT") + _CI_URL_VARS:
|
|
93
|
+
monkeypatch.delenv(name, raising=False)
|
|
94
|
+
# runpytest_subprocess starts a fresh interpreter: let it import the package from here.
|
|
95
|
+
root = str(pathlib.Path(__file__).resolve().parents[1])
|
|
96
|
+
existing = os.environ.get("PYTHONPATH")
|
|
97
|
+
monkeypatch.setenv("PYTHONPATH", root + (os.pathsep + existing if existing else ""))
|
|
98
|
+
# The local stand-in for Nexus is plain http on 127.0.0.1: only this switch allows it
|
|
99
|
+
# (SEC27-M4, 28/09/2026).
|
|
100
|
+
monkeypatch.setenv("NEXUS_ALLOW_INSECURE_LOCALHOST", "1")
|
|
101
|
+
return monkeypatch
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
SAMPLE = """
|
|
105
|
+
import pytest
|
|
106
|
+
|
|
107
|
+
def test_NEX_11_passes():
|
|
108
|
+
assert True
|
|
109
|
+
|
|
110
|
+
def test_NEX_42_fails():
|
|
111
|
+
assert 1 == 2
|
|
112
|
+
|
|
113
|
+
@pytest.mark.skip(reason="not today")
|
|
114
|
+
def test_NEX_7_skipped():
|
|
115
|
+
pass
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def test_resolve_config_prefers_options_then_env_then_ci():
|
|
120
|
+
env = {"NEXUS_URL": "https://e.example/t", "NEXUS_API_KEY": KEY, "NEXUS_PROJECT": "ENV", "NEXUS_ENVIRONMENT": "qa", "CI_PIPELINE_IID": "5", "CI_COMMIT_SHA": "abc"}
|
|
121
|
+
cfg = resolve_config({"project": "NEX", "fail_on_upload_error": True}, env)
|
|
122
|
+
assert (cfg.project, cfg.environment, cfg.build, cfg.commit, cfg.fail_on_upload_error) == ("NEX", "qa", "5", "abc", True)
|
|
123
|
+
assert resolve_config({"build": "9"}, env).build == "9"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def test_config_problem_names_what_is_wrong():
|
|
127
|
+
ok = NexusConfig(url="https://e.example/t", api_key=KEY, project="NEX")
|
|
128
|
+
assert config_problem(ok) is None
|
|
129
|
+
assert "NEXUS_URL" in config_problem(NexusConfig(api_key=KEY, project="NEX"))
|
|
130
|
+
assert "valid" in config_problem(NexusConfig(url="ftp://x", api_key=KEY, project="NEX"))
|
|
131
|
+
assert "NEXUS_API_KEY" in config_problem(NexusConfig(url="https://e.example/t", project="NEX"))
|
|
132
|
+
assert "project key" in config_problem(NexusConfig(url="https://e.example/t", api_key=KEY, project="nex"))
|
|
133
|
+
assert "issue key" in config_problem(NexusConfig(url="https://e.example/t", api_key=KEY, project="NEX", test_set="x"))
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def test_build_request_url_sets_given_params_and_keeps_existing_query():
|
|
137
|
+
cfg = NexusConfig(url="https://e.example/t?keep=1", project="NEX", environment="QA & Perf", test_set="NEX-25")
|
|
138
|
+
query = dict(urllib.parse.parse_qsl(urllib.parse.urlsplit(build_request_url(cfg)).query))
|
|
139
|
+
assert query == {"keep": "1", "project": "NEX", "format": "junit-xml", "environment": "QA & Perf", "testSet": "NEX-25"}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def test_post_results_resends_503_and_500_until_accepted():
|
|
143
|
+
flaky = FakeNexus([503, 500, 202])
|
|
144
|
+
try:
|
|
145
|
+
ok, _ = post_results(NexusConfig(url=flaky.url, allow_insecure_localhost=True, api_key=KEY, project="NEX"), b"<testsuites/>", retry_delay=0)
|
|
146
|
+
assert ok and len(flaky.requests) == 3
|
|
147
|
+
assert len({r["path"] + r["body"].decode() for r in flaky.requests}) == 1, "every send is the same upload"
|
|
148
|
+
finally:
|
|
149
|
+
flaky.close()
|
|
150
|
+
broken = FakeNexus([500])
|
|
151
|
+
try:
|
|
152
|
+
ok, message = post_results(NexusConfig(url=broken.url, allow_insecure_localhost=True, api_key=KEY, project="NEX"), b"<testsuites/>", retry_delay=0, max_attempts=3)
|
|
153
|
+
assert not ok and "HTTP 500" in message and "gave up after 3 sends" in message and len(broken.requests) == 3
|
|
154
|
+
finally:
|
|
155
|
+
broken.close()
|
|
156
|
+
refused = FakeNexus([400])
|
|
157
|
+
try:
|
|
158
|
+
ok, _ = post_results(NexusConfig(url=refused.url, allow_insecure_localhost=True, api_key=KEY, project="NEX"), b"<testsuites/>", retry_delay=0)
|
|
159
|
+
# 0.2.2: the envelope refused with 400 is sent once more as the raw XML, never again.
|
|
160
|
+
assert not ok and len(refused.requests) == 2
|
|
161
|
+
assert refused.requests[1]["body"] == b"<testsuites/>"
|
|
162
|
+
finally:
|
|
163
|
+
refused.close()
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def test_new_plugin_old_nexus_sends_the_raw_xml_once_after_a_400():
|
|
167
|
+
old = FakeNexus([400, 202])
|
|
168
|
+
try:
|
|
169
|
+
ok, _ = post_results(NexusConfig(url=old.url, allow_insecure_localhost=True, api_key=KEY, project="NEX"), b"<testsuites>\n<testsuite/>\n</testsuites>", retry_delay=0)
|
|
170
|
+
assert ok and len(old.requests) == 2
|
|
171
|
+
assert old.requests[0]["headers"]["Content-Type"] == "application/json"
|
|
172
|
+
assert old.requests[1]["headers"]["Content-Type"] == "application/xml"
|
|
173
|
+
assert old.requests[1]["body"] == b"<testsuites>\n<testsuite/>\n</testsuites>"
|
|
174
|
+
finally:
|
|
175
|
+
old.close()
|
|
176
|
+
new = FakeNexus([202])
|
|
177
|
+
try:
|
|
178
|
+
ok, _ = post_results(NexusConfig(url=new.url, allow_insecure_localhost=True, api_key=KEY, project="NEX"), b"<testsuites/>", retry_delay=0)
|
|
179
|
+
assert ok and len(new.requests) == 1 and unwrap(new.requests[0]["body"]) == b"<testsuites/>"
|
|
180
|
+
finally:
|
|
181
|
+
new.close()
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_0_2_3_every_send_of_one_upload_carries_the_same_idempotency_key():
|
|
185
|
+
server = FakeNexus([400, 500, 202], [b"", b"", b'{"status":"partial"}', b'{"status":"accepted"}'])
|
|
186
|
+
try:
|
|
187
|
+
ok, _ = post_results(NexusConfig(url=server.url, allow_insecure_localhost=True, api_key=KEY, project="NEX"), b"<testsuites/>", retry_delay=0)
|
|
188
|
+
assert ok and len(server.requests) == 4, "envelope refused, raw XML, 500, partial, accepted"
|
|
189
|
+
keys = {r["headers"]["Idempotency-Key"] for r in server.requests}
|
|
190
|
+
assert len(keys) == 1, "one key for the whole upload, fallback included"
|
|
191
|
+
key = keys.pop()
|
|
192
|
+
assert IDEMPOTENCY_KEY.match(key) and key.startswith("pytest-nexus-")
|
|
193
|
+
ok, _ = post_results(NexusConfig(url=server.url, allow_insecure_localhost=True, api_key=KEY, project="NEX"), b"<testsuites/>", retry_delay=0)
|
|
194
|
+
assert server.requests[-1]["headers"]["Idempotency-Key"] != key, "another upload, another key"
|
|
195
|
+
ok, _ = post_results(NexusConfig(url=server.url, allow_insecure_localhost=True, api_key=KEY, project="NEX", idempotency_key="build-42-pytest-report"), b"<testsuites/>", retry_delay=0)
|
|
196
|
+
assert server.requests[-1]["headers"]["Idempotency-Key"] == "build-42-pytest-report"
|
|
197
|
+
finally:
|
|
198
|
+
server.close()
|
|
199
|
+
assert new_idempotency_key() != new_idempotency_key()
|
|
200
|
+
assert resolve_config({}, {"NEXUS_IDEMPOTENCY_KEY": "from-the-environment"}).idempotency_key == "from-the-environment"
|
|
201
|
+
assert "idempotency key" in config_problem(NexusConfig(url="https://e.example/t", api_key=KEY, project="NEX", idempotency_key="short"))
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def test_0_2_3_ci_build_url_from_each_ci_system_https_only():
|
|
205
|
+
assert ci_build_url({"GITHUB_SERVER_URL": "https://github.com/", "GITHUB_REPOSITORY": "acme/shop", "GITHUB_RUN_ID": "42"}) == "https://github.com/acme/shop/actions/runs/42"
|
|
206
|
+
assert ci_build_url({"CI_JOB_URL": "https://gitlab.com/acme/shop/-/jobs/7"}) == "https://gitlab.com/acme/shop/-/jobs/7"
|
|
207
|
+
assert (
|
|
208
|
+
ci_build_url({"SYSTEM_TEAMFOUNDATIONCOLLECTIONURI": "https://dev.azure.com/acme/", "SYSTEM_TEAMPROJECT": "Shop App", "BUILD_BUILDID": "9"})
|
|
209
|
+
== "https://dev.azure.com/acme/Shop%20App/_build/results?buildId=9"
|
|
210
|
+
)
|
|
211
|
+
assert ci_build_url({"BUILD_URL": "https://jenkins.acme.test/job/shop/12/"}) == "https://jenkins.acme.test/job/shop/12/"
|
|
212
|
+
assert ci_build_url({"BITBUCKET_REPO_FULL_NAME": "acme/shop", "BITBUCKET_BUILD_NUMBER": "3"}) == "https://bitbucket.org/acme/shop/pipelines/results/3"
|
|
213
|
+
assert ci_build_url({"CIRCLE_BUILD_URL": "https://app.circleci.com/pipelines/gh/acme/shop/5"}) == "https://app.circleci.com/pipelines/gh/acme/shop/5"
|
|
214
|
+
assert ci_build_url({"BUILD_URL": "http://jenkins.local/job/1/"}) is None, "plain http is never sent"
|
|
215
|
+
assert ci_build_url({"BUILD_URL": "https://user:pw@jenkins.acme.test/job/1/"}) is None, "no credentials in the URL"
|
|
216
|
+
assert ci_build_url({}) is None
|
|
217
|
+
env = {"CI_JOB_URL": "https://gitlab.com/acme/shop/-/jobs/7"}
|
|
218
|
+
assert resolve_config({}, env).build_url == "https://gitlab.com/acme/shop/-/jobs/7"
|
|
219
|
+
assert resolve_config({"build_url": "https://ci.example/1"}, env).build_url == "https://ci.example/1"
|
|
220
|
+
assert resolve_config({}, {**env, "NEXUS_BUILD_URL": "https://ci.example/2"}).build_url == "https://ci.example/2"
|
|
221
|
+
assert resolve_config({"build_url": "http://ci.example/1"}, env).build_url == "https://gitlab.com/acme/shop/-/jobs/7", "http dropped, CI page used"
|
|
222
|
+
query = dict(urllib.parse.parse_qsl(urllib.parse.urlsplit(build_request_url(resolve_config({"url": "https://e.example/t", "project": "NEX"}, env))).query))
|
|
223
|
+
assert query["buildUrl"] == "https://gitlab.com/acme/shop/-/jobs/7"
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def test_post_results_follows_a_partial_202():
|
|
227
|
+
server = FakeNexus([202], [b'{"status":"partial","reason":"time budget reached"}', b'{"status":"accepted"}'])
|
|
228
|
+
try:
|
|
229
|
+
pauses = []
|
|
230
|
+
ok, _ = post_results(NexusConfig(url=server.url, allow_insecure_localhost=True, api_key=KEY, project="NEX"), b"<testsuites/>", retry_delay=2.0, sleep=pauses.append)
|
|
231
|
+
assert ok and len(server.requests) == 2 and pauses == [2.0]
|
|
232
|
+
finally:
|
|
233
|
+
server.close()
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def test_post_results_pause_grows_and_is_capped():
|
|
237
|
+
server = FakeNexus([503])
|
|
238
|
+
try:
|
|
239
|
+
pauses = []
|
|
240
|
+
ok, _ = post_results(NexusConfig(url=server.url, allow_insecure_localhost=True, api_key=KEY, project="NEX"), b"x", retry_delay=2.0, sleep=pauses.append)
|
|
241
|
+
assert not ok and pauses == [2.0, 4.0, 8.0, 15.0, 15.0, 15.0, 15.0]
|
|
242
|
+
finally:
|
|
243
|
+
server.close()
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def test_network_error_message_never_has_the_key():
|
|
247
|
+
def opener(request, timeout):
|
|
248
|
+
raise urllib.error.URLError(f"refused {KEY}")
|
|
249
|
+
|
|
250
|
+
ok, message = post_results(NexusConfig(url="https://e.example/t", api_key=KEY, project="NEX"), b"x", opener=opener, retry_delay=0)
|
|
251
|
+
assert not ok and KEY not in message and "***" in message
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def test_session_posts_junit_xml_after_the_run(pytester, nexus, clean_env):
|
|
255
|
+
clean_env.setenv("NEXUS_URL", nexus.url)
|
|
256
|
+
clean_env.setenv("NEXUS_API_KEY", KEY)
|
|
257
|
+
clean_env.setenv("NEXUS_PROJECT", "NEX")
|
|
258
|
+
clean_env.setenv("GITHUB_RUN_ID", "77")
|
|
259
|
+
pytester.makepyfile(test_sample=SAMPLE)
|
|
260
|
+
result = pytester.runpytest_subprocess(*PLUGIN_ARGS, "--nexus-environment", "staging", "--nexus-test-set", "NEX-25", "--nexus-component", "api")
|
|
261
|
+
|
|
262
|
+
result.assert_outcomes(passed=1, failed=1, skipped=1)
|
|
263
|
+
assert result.ret == pytest.ExitCode.TESTS_FAILED
|
|
264
|
+
assert len(nexus.requests) == 1
|
|
265
|
+
req = nexus.requests[0]
|
|
266
|
+
assert req["headers"]["Authorization"] == f"Bearer {KEY}"
|
|
267
|
+
assert req["headers"]["Content-Type"] == "application/json"
|
|
268
|
+
assert nexus.query() == {"project": "NEX", "format": "junit-xml", "environment": "staging", "testSet": "NEX-25", "build": "77", "component": "api"}
|
|
269
|
+
assert b"\n" not in req["body"] and b"\r" not in req["body"], "no raw line break travels (e2e F1)"
|
|
270
|
+
body = unwrap(req["body"]).decode()
|
|
271
|
+
assert "\n" in body
|
|
272
|
+
assert body.lstrip().startswith("<?xml") and "test_NEX_11_passes" in body and "test_NEX_42_fails" in body
|
|
273
|
+
output = result.stdout.str() + result.stderr.str()
|
|
274
|
+
assert "nexus: sent results to project NEX" in output
|
|
275
|
+
assert KEY not in output and "secret-path" not in output
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def test_0_2_3_session_sends_build_url_and_an_idempotency_key(pytester, nexus, clean_env):
|
|
279
|
+
clean_env.setenv("NEXUS_URL", nexus.url)
|
|
280
|
+
clean_env.setenv("NEXUS_API_KEY", KEY)
|
|
281
|
+
clean_env.setenv("NEXUS_PROJECT", "NEX")
|
|
282
|
+
clean_env.setenv("GITHUB_SERVER_URL", "https://github.com")
|
|
283
|
+
clean_env.setenv("GITHUB_REPOSITORY", "acme/shop")
|
|
284
|
+
clean_env.setenv("GITHUB_RUN_ID", "77")
|
|
285
|
+
pytester.makepyfile(test_sample=SAMPLE)
|
|
286
|
+
pytester.runpytest_subprocess(*PLUGIN_ARGS)
|
|
287
|
+
pytester.runpytest_subprocess(*PLUGIN_ARGS, "--nexus-build-url", "https://ci.example/job/9", "--nexus-idempotency-key", "build-77-pytest-report")
|
|
288
|
+
assert nexus.query(0)["buildUrl"] == "https://github.com/acme/shop/actions/runs/77"
|
|
289
|
+
assert IDEMPOTENCY_KEY.match(nexus.requests[0]["headers"]["Idempotency-Key"])
|
|
290
|
+
assert nexus.query(1)["buildUrl"] == "https://ci.example/job/9"
|
|
291
|
+
assert nexus.requests[1]["headers"]["Idempotency-Key"] == "build-77-pytest-report"
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
def test_existing_junitxml_path_is_reused(pytester, nexus, clean_env):
|
|
295
|
+
clean_env.setenv("NEXUS_API_KEY", KEY)
|
|
296
|
+
pytester.makepyfile(test_sample=SAMPLE)
|
|
297
|
+
result = pytester.runpytest_subprocess(*PLUGIN_ARGS, "--junitxml=out/report.xml", "--nexus-url", nexus.url, "--nexus-project", "NEX")
|
|
298
|
+
assert (pytester.path / "out" / "report.xml").read_bytes() == unwrap(nexus.requests[0]["body"])
|
|
299
|
+
assert result.ret == pytest.ExitCode.TESTS_FAILED
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def test_does_nothing_without_a_url(pytester, clean_env):
|
|
303
|
+
pytester.makepyfile(test_ok="def test_NEX_1_ok():\n assert True\n")
|
|
304
|
+
result = pytester.runpytest_subprocess(*PLUGIN_ARGS)
|
|
305
|
+
result.assert_outcomes(passed=1)
|
|
306
|
+
assert "nexus:" not in result.stdout.str()
|
|
307
|
+
assert not list(pytester.path.glob("**/*.xml"))
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def test_no_nexus_flag_turns_it_off(pytester, nexus, clean_env):
|
|
311
|
+
clean_env.setenv("NEXUS_URL", nexus.url)
|
|
312
|
+
clean_env.setenv("NEXUS_API_KEY", KEY)
|
|
313
|
+
clean_env.setenv("NEXUS_PROJECT", "NEX")
|
|
314
|
+
pytester.makepyfile(test_ok="def test_NEX_1_ok():\n assert True\n")
|
|
315
|
+
pytester.runpytest_subprocess(*PLUGIN_ARGS, "--no-nexus").assert_outcomes(passed=1)
|
|
316
|
+
assert nexus.requests == []
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def test_upload_failure_warns_but_keeps_the_exit_code(pytester, clean_env):
|
|
320
|
+
server = FakeNexus([401])
|
|
321
|
+
try:
|
|
322
|
+
clean_env.setenv("NEXUS_URL", server.url)
|
|
323
|
+
clean_env.setenv("NEXUS_API_KEY", KEY)
|
|
324
|
+
clean_env.setenv("NEXUS_PROJECT", "NEX")
|
|
325
|
+
pytester.makepyfile(test_ok="def test_NEX_1_ok():\n assert True\n")
|
|
326
|
+
result = pytester.runpytest_subprocess(*PLUGIN_ARGS)
|
|
327
|
+
assert result.ret == pytest.ExitCode.OK
|
|
328
|
+
assert "HTTP 401" in result.stdout.str()
|
|
329
|
+
|
|
330
|
+
strict = pytester.runpytest_subprocess(*PLUGIN_ARGS, "--nexus-fail-on-upload-error")
|
|
331
|
+
assert strict.ret == pytest.ExitCode.TESTS_FAILED
|
|
332
|
+
finally:
|
|
333
|
+
server.close()
|
|
334
|
+
|
|
335
|
+
|
|
336
|
+
def test_missing_project_is_reported_not_sent(pytester, nexus, clean_env):
|
|
337
|
+
clean_env.setenv("NEXUS_URL", nexus.url)
|
|
338
|
+
clean_env.setenv("NEXUS_API_KEY", KEY)
|
|
339
|
+
pytester.makepyfile(test_ok="def test_NEX_1_ok():\n assert True\n")
|
|
340
|
+
result = pytester.runpytest_subprocess(*PLUGIN_ARGS)
|
|
341
|
+
assert result.ret == pytest.ExitCode.OK
|
|
342
|
+
assert "results not sent: NEXUS_PROJECT is not set" in result.stdout.str()
|
|
343
|
+
assert nexus.requests == []
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
# SEC27-M4 (28/09/2026): https only; http only to this machine behind the test switch;
|
|
347
|
+
# redirects are never followed, so the bearer key never goes anywhere else.
|
|
348
|
+
def test_transport_problem_allows_https_and_only_switched_localhost_http():
|
|
349
|
+
from pytest_nexus.plugin import transport_problem
|
|
350
|
+
|
|
351
|
+
def cfg(url, allow=False):
|
|
352
|
+
return NexusConfig(url=url, api_key=KEY, project="NEX", allow_insecure_localhost=allow)
|
|
353
|
+
|
|
354
|
+
assert transport_problem(cfg("https://e.example/t")) is None
|
|
355
|
+
assert "https" in transport_problem(cfg("http://e.example/t", allow=True)), "the switch never opens http to another host"
|
|
356
|
+
assert "https" in transport_problem(cfg("http://127.0.0.1:1/t")), "localhost still needs the switch"
|
|
357
|
+
for url in ("http://127.0.0.1:1/t", "http://localhost/t", "http://[::1]:9/t"):
|
|
358
|
+
assert transport_problem(cfg(url, allow=True)) is None, url
|
|
359
|
+
assert "https" in transport_problem(cfg("http://127.0.0.1.evil.example/t", allow=True))
|
|
360
|
+
assert resolve_config({}, {"NEXUS_ALLOW_INSECURE_LOCALHOST": "true"}).allow_insecure_localhost is False
|
|
361
|
+
assert resolve_config({}, {"NEXUS_ALLOW_INSECURE_LOCALHOST": "1"}).allow_insecure_localhost is True
|
|
362
|
+
|
|
363
|
+
|
|
364
|
+
def test_post_results_refuses_http_before_sending():
|
|
365
|
+
def opener(request, timeout):
|
|
366
|
+
raise AssertionError("no request")
|
|
367
|
+
|
|
368
|
+
ok, message = post_results(NexusConfig(url="http://e.example/t", api_key=KEY, project="NEX"), b"x", opener=opener)
|
|
369
|
+
assert not ok and "https" in message and KEY not in message
|
|
370
|
+
local = FakeNexus([202])
|
|
371
|
+
try:
|
|
372
|
+
ok, _ = post_results(NexusConfig(url=local.url, api_key=KEY, project="NEX"), b"x")
|
|
373
|
+
assert not ok and local.requests == [], "localhost http without the switch sends nothing"
|
|
374
|
+
finally:
|
|
375
|
+
local.close()
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
@pytest.mark.parametrize("status", [301, 302, 303, 307, 308])
|
|
379
|
+
def test_redirect_is_not_followed_and_the_key_is_not_forwarded(status):
|
|
380
|
+
elsewhere = FakeNexus([202])
|
|
381
|
+
|
|
382
|
+
class Redirect(http.server.BaseHTTPRequestHandler):
|
|
383
|
+
def do_POST(self): # noqa: N802
|
|
384
|
+
self.rfile.read(int(self.headers.get("Content-Length", 0)))
|
|
385
|
+
self.send_response(status)
|
|
386
|
+
self.send_header("Location", elsewhere.url)
|
|
387
|
+
self.send_header("Content-Length", "0")
|
|
388
|
+
self.end_headers()
|
|
389
|
+
|
|
390
|
+
def do_GET(self): # noqa: N802
|
|
391
|
+
self.do_POST()
|
|
392
|
+
|
|
393
|
+
def log_message(self, *args):
|
|
394
|
+
pass
|
|
395
|
+
|
|
396
|
+
server = http.server.ThreadingHTTPServer(("127.0.0.1", 0), Redirect)
|
|
397
|
+
threading.Thread(target=server.serve_forever, daemon=True).start()
|
|
398
|
+
try:
|
|
399
|
+
url = f"http://127.0.0.1:{server.server_address[1]}/x1/secret-path"
|
|
400
|
+
ok, message = post_results(NexusConfig(url=url, api_key=KEY, project="NEX", allow_insecure_localhost=True), b"<testsuites/>", retry_delay=0)
|
|
401
|
+
assert not ok and f"HTTP {status} (a redirect, not followed" in message
|
|
402
|
+
assert elsewhere.requests == [], "the redirect target received nothing"
|
|
403
|
+
finally:
|
|
404
|
+
server.shutdown()
|
|
405
|
+
server.server_close()
|
|
406
|
+
elsewhere.close()
|
|
407
|
+
|
|
408
|
+
|
|
409
|
+
def test_session_refuses_an_http_url_without_the_switch(pytester, nexus, clean_env):
|
|
410
|
+
clean_env.delenv("NEXUS_ALLOW_INSECURE_LOCALHOST")
|
|
411
|
+
clean_env.setenv("NEXUS_URL", nexus.url)
|
|
412
|
+
clean_env.setenv("NEXUS_API_KEY", KEY)
|
|
413
|
+
clean_env.setenv("NEXUS_PROJECT", "NEX")
|
|
414
|
+
pytester.makepyfile(test_ok="def test_NEX_1_ok():\n assert True\n")
|
|
415
|
+
result = pytester.runpytest_subprocess(*PLUGIN_ARGS)
|
|
416
|
+
assert "results not sent: NEXUS_URL is not a valid https URL" in result.stdout.str()
|
|
417
|
+
assert nexus.requests == []
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def test_transport_body_keeps_a_multi_line_trace():
|
|
421
|
+
xml = b'<testsuite><testcase name="test_NEX_42"><failure>E assert 1 == 2\r\n\ntests/test_shop.py:43: AssertionError</failure></testcase></testsuite>'
|
|
422
|
+
data, content_type, encoding = transport_body(xml)
|
|
423
|
+
assert (content_type, encoding) == ("application/json", "base64")
|
|
424
|
+
assert unwrap(data) == xml
|
|
425
|
+
big = b"<testsuite>" + b"abcdefghij\n" * 400_000 + b"</testsuite>"
|
|
426
|
+
data, _, encoding = transport_body(big)
|
|
427
|
+
assert encoding == "text" and len(data) <= MAX_BODY_BYTES and unwrap(data) == big
|
|
428
|
+
huge = b"<testsuite>" + b'"' * 4_500_000 + b"</testsuite>"
|
|
429
|
+
assert transport_body(huge) == (huge, "application/xml", "none")
|