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.
@@ -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,5 @@
1
+ """pytest-nexus: send pytest results to Nexus for Jira."""
2
+
3
+ from .plugin import VERSION as __version__
4
+
5
+ __all__ = ["__version__"]
@@ -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")