swaggerforge 0.3.0__tar.gz → 0.3.1__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.
- {swaggerforge-0.3.0/src/swaggerforge.egg-info → swaggerforge-0.3.1}/PKG-INFO +67 -24
- {swaggerforge-0.3.0 → swaggerforge-0.3.1}/README.md +64 -23
- {swaggerforge-0.3.0 → swaggerforge-0.3.1}/pyproject.toml +2 -1
- swaggerforge-0.3.1/src/swaggerforge/auth.py +274 -0
- {swaggerforge-0.3.0 → swaggerforge-0.3.1}/src/swaggerforge/config.py +13 -0
- {swaggerforge-0.3.0 → swaggerforge-0.3.1}/src/swaggerforge/models.py +1 -1
- {swaggerforge-0.3.0 → swaggerforge-0.3.1}/src/swaggerforge/output.py +37 -5
- {swaggerforge-0.3.0 → swaggerforge-0.3.1}/src/swaggerforge/parser.py +7 -8
- swaggerforge-0.3.1/src/swaggerforge/template.py +215 -0
- {swaggerforge-0.3.0 → swaggerforge-0.3.1}/src/swaggerforge/templates/test_file.py.j2 +14 -3
- {swaggerforge-0.3.0 → swaggerforge-0.3.1/src/swaggerforge.egg-info}/PKG-INFO +67 -24
- {swaggerforge-0.3.0 → swaggerforge-0.3.1}/src/swaggerforge.egg-info/requires.txt +3 -0
- swaggerforge-0.3.1/tests/test_auth.py +523 -0
- {swaggerforge-0.3.0 → swaggerforge-0.3.1}/tests/test_config.py +76 -0
- swaggerforge-0.3.1/tests/test_e2e_auth.py +253 -0
- {swaggerforge-0.3.0 → swaggerforge-0.3.1}/tests/test_output.py +86 -3
- {swaggerforge-0.3.0 → swaggerforge-0.3.1}/tests/test_parser.py +43 -3
- swaggerforge-0.3.1/tests/test_template.py +290 -0
- swaggerforge-0.3.0/src/swaggerforge/auth.py +0 -143
- swaggerforge-0.3.0/src/swaggerforge/template.py +0 -132
- swaggerforge-0.3.0/tests/test_auth.py +0 -142
- swaggerforge-0.3.0/tests/test_e2e_auth.py +0 -109
- swaggerforge-0.3.0/tests/test_template.py +0 -138
- {swaggerforge-0.3.0 → swaggerforge-0.3.1}/LICENSE +0 -0
- {swaggerforge-0.3.0 → swaggerforge-0.3.1}/setup.cfg +0 -0
- {swaggerforge-0.3.0 → swaggerforge-0.3.1}/src/swaggerforge/__init__.py +0 -0
- {swaggerforge-0.3.0 → swaggerforge-0.3.1}/src/swaggerforge/__main__.py +0 -0
- {swaggerforge-0.3.0 → swaggerforge-0.3.1}/src/swaggerforge/cli.py +0 -0
- {swaggerforge-0.3.0 → swaggerforge-0.3.1}/src/swaggerforge/generator.py +0 -0
- {swaggerforge-0.3.0 → swaggerforge-0.3.1}/src/swaggerforge/validator.py +0 -0
- {swaggerforge-0.3.0 → swaggerforge-0.3.1}/src/swaggerforge.egg-info/SOURCES.txt +0 -0
- {swaggerforge-0.3.0 → swaggerforge-0.3.1}/src/swaggerforge.egg-info/dependency_links.txt +0 -0
- {swaggerforge-0.3.0 → swaggerforge-0.3.1}/src/swaggerforge.egg-info/entry_points.txt +0 -0
- {swaggerforge-0.3.0 → swaggerforge-0.3.1}/src/swaggerforge.egg-info/top_level.txt +0 -0
- {swaggerforge-0.3.0 → swaggerforge-0.3.1}/tests/test_generator.py +0 -0
- {swaggerforge-0.3.0 → swaggerforge-0.3.1}/tests/test_validator.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: swaggerforge
|
|
3
|
-
Version: 0.3.
|
|
3
|
+
Version: 0.3.1
|
|
4
4
|
Summary: Automatic pytest test generation from OpenAPI (Swagger) specifications
|
|
5
5
|
Author: Viktor Pylypenko
|
|
6
6
|
License-Expression: MIT
|
|
@@ -20,6 +20,8 @@ Requires-Dist: pytest>=9.0; extra == "dev"
|
|
|
20
20
|
Requires-Dist: flake8>=7.0; extra == "dev"
|
|
21
21
|
Requires-Dist: pytest-cov>=7.0; extra == "dev"
|
|
22
22
|
Requires-Dist: tox>=4.0; extra == "dev"
|
|
23
|
+
Provides-Extra: oauth2
|
|
24
|
+
Requires-Dist: requests-oauth2client>=1.6; extra == "oauth2"
|
|
23
25
|
Dynamic: license-file
|
|
24
26
|
|
|
25
27
|
# SwaggerForge
|
|
@@ -48,8 +50,9 @@ established test-design techniques.
|
|
|
48
50
|
- Deterministic output: the same specification always produces identical tests
|
|
49
51
|
- Generated files use session-scoped pytest fixtures and run with no manual edits
|
|
50
52
|
- Optional `swaggerforge.toml` config file for project-level defaults
|
|
51
|
-
-
|
|
52
|
-
|
|
53
|
+
- Comprehensive authentication: bearer, basic, API key (header/query/cookie),
|
|
54
|
+
multiple-scheme AND/OR requirements, and OAuth2 / OpenID Connect (optional
|
|
55
|
+
extra), with environment-variable substitution for secrets
|
|
53
56
|
|
|
54
57
|
## Requirements
|
|
55
58
|
|
|
@@ -113,9 +116,10 @@ setting one makes test runs fail fast when the API is unreachable.
|
|
|
113
116
|
|
|
114
117
|
## Authentication
|
|
115
118
|
|
|
116
|
-
When a specification declares security requirements, SwaggerForge
|
|
117
|
-
|
|
118
|
-
|
|
119
|
+
When a specification declares security requirements, SwaggerForge configures
|
|
120
|
+
the generated tests to authenticate. Credentials are supplied through the
|
|
121
|
+
config file; the specification decides *which* scheme and *where* each value
|
|
122
|
+
goes, and the config supplies the secret values.
|
|
119
123
|
|
|
120
124
|
```toml
|
|
121
125
|
# swaggerforge.toml
|
|
@@ -128,28 +132,65 @@ value = "${API_KEY}"
|
|
|
128
132
|
[auth.basic]
|
|
129
133
|
username = "${API_USER}"
|
|
130
134
|
password = "${API_PASSWORD}"
|
|
135
|
+
|
|
136
|
+
[auth.oauth2]
|
|
137
|
+
client_id = "${OAUTH_CLIENT_ID}"
|
|
138
|
+
client_secret = "${OAUTH_CLIENT_SECRET}"
|
|
139
|
+
scope = "read write"
|
|
131
140
|
```
|
|
132
141
|
|
|
133
|
-
|
|
142
|
+
Generated tests build a session-scoped `requests.Session` that carries the
|
|
143
|
+
resolved authentication, so every request is authenticated without repeating
|
|
144
|
+
credentials per call.
|
|
145
|
+
|
|
146
|
+
### Supported schemes
|
|
147
|
+
|
|
148
|
+
| Config section | OpenAPI scheme | How it is applied |
|
|
149
|
+
|-----------------|---------------------------------------------|-----------------------------------------------------|
|
|
150
|
+
| `[auth.bearer]` | `http` bearer | `Authorization: Bearer <token>` header |
|
|
151
|
+
| `[auth.api_key]`| `apiKey` in header, query, or cookie | the name and location declared by the specification |
|
|
152
|
+
| `[auth.basic]` | `http` basic | `Authorization: Basic <base64>` header |
|
|
153
|
+
| `[auth.oauth2]` | `oauth2` client credentials, `openIdConnect`| a token fetched at runtime (see below) |
|
|
154
|
+
|
|
155
|
+
For an API key, both the **name** and the **location** (header, query, or
|
|
156
|
+
cookie) come from the specification; the config supplies only the secret
|
|
157
|
+
`value`.
|
|
158
|
+
|
|
159
|
+
### Multiple schemes
|
|
160
|
+
|
|
161
|
+
OpenAPI can require several schemes at once (AND) or offer alternatives (OR).
|
|
162
|
+
SwaggerForge honours both: it uses the first alternative whose credentials are
|
|
163
|
+
fully configured, combining every scheme in an AND group. When endpoints in one
|
|
164
|
+
file need different authentication, the differing ones override the session per
|
|
165
|
+
request.
|
|
166
|
+
|
|
167
|
+
### OAuth2 and OpenID Connect
|
|
168
|
+
|
|
169
|
+
OAuth2 and OIDC support is an optional extra, installed with:
|
|
170
|
+
|
|
171
|
+
```bash
|
|
172
|
+
pip install swaggerforge[oauth2]
|
|
173
|
+
```
|
|
134
174
|
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
175
|
+
For the **client credentials** flow, generated tests fetch a fresh access
|
|
176
|
+
token from the specification's `tokenUrl` at runtime (and refresh it on
|
|
177
|
+
expiry), so no token is ever embedded at generation time. **OpenID Connect**
|
|
178
|
+
schemes work the same way, discovering the token endpoint at runtime from the
|
|
179
|
+
specification's `openIdConnectUrl`. Both need `client_id` and `client_secret`
|
|
180
|
+
in `[auth.oauth2]`.
|
|
140
181
|
|
|
141
|
-
|
|
142
|
-
|
|
182
|
+
Interactive flows (OAuth2 **authorization code**) require a human to log in
|
|
183
|
+
through a browser and so cannot be fully automated. For these, SwaggerForge
|
|
184
|
+
generates a clearly marked placeholder in the session fixture where you paste a
|
|
185
|
+
token obtained manually.
|
|
143
186
|
|
|
144
|
-
|
|
145
|
-
variable with `${VAR}` syntax, so secrets stay out of the config file. A
|
|
146
|
-
referenced variable that is not set is an error.
|
|
187
|
+
### Environment variables and missing credentials
|
|
147
188
|
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
189
|
+
Any auth value may reference an environment variable with `${VAR}` syntax, so
|
|
190
|
+
secrets stay out of the config file; a referenced variable that is not set is
|
|
191
|
+
an error. If a specification requires a scheme whose credentials are not
|
|
192
|
+
configured, SwaggerForge prints a warning naming the scheme and generates the
|
|
193
|
+
tests without that authentication rather than failing.
|
|
153
194
|
|
|
154
195
|
## How it works
|
|
155
196
|
|
|
@@ -161,8 +202,10 @@ written to per-resource files.
|
|
|
161
202
|
## Limitations
|
|
162
203
|
|
|
163
204
|
- Targets OpenAPI 3.x with JSON request bodies
|
|
164
|
-
- Authentication covers bearer, API key (header),
|
|
165
|
-
|
|
205
|
+
- Authentication covers bearer, basic, API key (header/query/cookie), OAuth2
|
|
206
|
+
client credentials, and OpenID Connect; interactive OAuth2 flows
|
|
207
|
+
(authorization code) generate a placeholder for a manually obtained token,
|
|
208
|
+
as they require a browser-based login that cannot be automated
|
|
166
209
|
- Boundary tests require the specification to declare numeric/length constraints
|
|
167
210
|
|
|
168
211
|
## License
|
|
@@ -24,8 +24,9 @@ established test-design techniques.
|
|
|
24
24
|
- Deterministic output: the same specification always produces identical tests
|
|
25
25
|
- Generated files use session-scoped pytest fixtures and run with no manual edits
|
|
26
26
|
- Optional `swaggerforge.toml` config file for project-level defaults
|
|
27
|
-
-
|
|
28
|
-
|
|
27
|
+
- Comprehensive authentication: bearer, basic, API key (header/query/cookie),
|
|
28
|
+
multiple-scheme AND/OR requirements, and OAuth2 / OpenID Connect (optional
|
|
29
|
+
extra), with environment-variable substitution for secrets
|
|
29
30
|
|
|
30
31
|
## Requirements
|
|
31
32
|
|
|
@@ -89,9 +90,10 @@ setting one makes test runs fail fast when the API is unreachable.
|
|
|
89
90
|
|
|
90
91
|
## Authentication
|
|
91
92
|
|
|
92
|
-
When a specification declares security requirements, SwaggerForge
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
When a specification declares security requirements, SwaggerForge configures
|
|
94
|
+
the generated tests to authenticate. Credentials are supplied through the
|
|
95
|
+
config file; the specification decides *which* scheme and *where* each value
|
|
96
|
+
goes, and the config supplies the secret values.
|
|
95
97
|
|
|
96
98
|
```toml
|
|
97
99
|
# swaggerforge.toml
|
|
@@ -104,28 +106,65 @@ value = "${API_KEY}"
|
|
|
104
106
|
[auth.basic]
|
|
105
107
|
username = "${API_USER}"
|
|
106
108
|
password = "${API_PASSWORD}"
|
|
109
|
+
|
|
110
|
+
[auth.oauth2]
|
|
111
|
+
client_id = "${OAUTH_CLIENT_ID}"
|
|
112
|
+
client_secret = "${OAUTH_CLIENT_SECRET}"
|
|
113
|
+
scope = "read write"
|
|
107
114
|
```
|
|
108
115
|
|
|
109
|
-
|
|
116
|
+
Generated tests build a session-scoped `requests.Session` that carries the
|
|
117
|
+
resolved authentication, so every request is authenticated without repeating
|
|
118
|
+
credentials per call.
|
|
119
|
+
|
|
120
|
+
### Supported schemes
|
|
121
|
+
|
|
122
|
+
| Config section | OpenAPI scheme | How it is applied |
|
|
123
|
+
|-----------------|---------------------------------------------|-----------------------------------------------------|
|
|
124
|
+
| `[auth.bearer]` | `http` bearer | `Authorization: Bearer <token>` header |
|
|
125
|
+
| `[auth.api_key]`| `apiKey` in header, query, or cookie | the name and location declared by the specification |
|
|
126
|
+
| `[auth.basic]` | `http` basic | `Authorization: Basic <base64>` header |
|
|
127
|
+
| `[auth.oauth2]` | `oauth2` client credentials, `openIdConnect`| a token fetched at runtime (see below) |
|
|
128
|
+
|
|
129
|
+
For an API key, both the **name** and the **location** (header, query, or
|
|
130
|
+
cookie) come from the specification; the config supplies only the secret
|
|
131
|
+
`value`.
|
|
132
|
+
|
|
133
|
+
### Multiple schemes
|
|
134
|
+
|
|
135
|
+
OpenAPI can require several schemes at once (AND) or offer alternatives (OR).
|
|
136
|
+
SwaggerForge honours both: it uses the first alternative whose credentials are
|
|
137
|
+
fully configured, combining every scheme in an AND group. When endpoints in one
|
|
138
|
+
file need different authentication, the differing ones override the session per
|
|
139
|
+
request.
|
|
140
|
+
|
|
141
|
+
### OAuth2 and OpenID Connect
|
|
142
|
+
|
|
143
|
+
OAuth2 and OIDC support is an optional extra, installed with:
|
|
144
|
+
|
|
145
|
+
```bash
|
|
146
|
+
pip install swaggerforge[oauth2]
|
|
147
|
+
```
|
|
110
148
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
149
|
+
For the **client credentials** flow, generated tests fetch a fresh access
|
|
150
|
+
token from the specification's `tokenUrl` at runtime (and refresh it on
|
|
151
|
+
expiry), so no token is ever embedded at generation time. **OpenID Connect**
|
|
152
|
+
schemes work the same way, discovering the token endpoint at runtime from the
|
|
153
|
+
specification's `openIdConnectUrl`. Both need `client_id` and `client_secret`
|
|
154
|
+
in `[auth.oauth2]`.
|
|
116
155
|
|
|
117
|
-
|
|
118
|
-
|
|
156
|
+
Interactive flows (OAuth2 **authorization code**) require a human to log in
|
|
157
|
+
through a browser and so cannot be fully automated. For these, SwaggerForge
|
|
158
|
+
generates a clearly marked placeholder in the session fixture where you paste a
|
|
159
|
+
token obtained manually.
|
|
119
160
|
|
|
120
|
-
|
|
121
|
-
variable with `${VAR}` syntax, so secrets stay out of the config file. A
|
|
122
|
-
referenced variable that is not set is an error.
|
|
161
|
+
### Environment variables and missing credentials
|
|
123
162
|
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
163
|
+
Any auth value may reference an environment variable with `${VAR}` syntax, so
|
|
164
|
+
secrets stay out of the config file; a referenced variable that is not set is
|
|
165
|
+
an error. If a specification requires a scheme whose credentials are not
|
|
166
|
+
configured, SwaggerForge prints a warning naming the scheme and generates the
|
|
167
|
+
tests without that authentication rather than failing.
|
|
129
168
|
|
|
130
169
|
## How it works
|
|
131
170
|
|
|
@@ -137,8 +176,10 @@ written to per-resource files.
|
|
|
137
176
|
## Limitations
|
|
138
177
|
|
|
139
178
|
- Targets OpenAPI 3.x with JSON request bodies
|
|
140
|
-
- Authentication covers bearer, API key (header),
|
|
141
|
-
|
|
179
|
+
- Authentication covers bearer, basic, API key (header/query/cookie), OAuth2
|
|
180
|
+
client credentials, and OpenID Connect; interactive OAuth2 flows
|
|
181
|
+
(authorization code) generate a placeholder for a manually obtained token,
|
|
182
|
+
as they require a browser-based login that cannot be automated
|
|
142
183
|
- Boundary tests require the specification to declare numeric/length constraints
|
|
143
184
|
|
|
144
185
|
## License
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "swaggerforge"
|
|
7
|
-
version = "0.3.
|
|
7
|
+
version = "0.3.1"
|
|
8
8
|
description = "Automatic pytest test generation from OpenAPI (Swagger) specifications"
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
requires-python = ">=3.10"
|
|
@@ -39,3 +39,4 @@ testpaths = ["tests"]
|
|
|
39
39
|
|
|
40
40
|
[project.optional-dependencies]
|
|
41
41
|
dev = ["pytest>=9.0", "flake8>=7.0", "pytest-cov>=7.0", "tox>=4.0"]
|
|
42
|
+
oauth2 = ["requests-oauth2client>=1.6"]
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
"""Resolution of authentication for generated tests.
|
|
2
|
+
|
|
3
|
+
Given an endpoint's declared security requirement, the specification's
|
|
4
|
+
security schemes, and the user's configured credentials, this module
|
|
5
|
+
decides which authentication headers to inject into the generated tests.
|
|
6
|
+
|
|
7
|
+
Supported schemes (v0.3.0): HTTP bearer, HTTP basic, and API key in a
|
|
8
|
+
header. OAuth2 is treated as bearer (the user supplies a pre-obtained
|
|
9
|
+
token). Other schemes - API key in query or cookie, OpenID Connect,
|
|
10
|
+
and any undefined scheme name - are skipped with a warning.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
import base64
|
|
14
|
+
from typing import NamedTuple
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
class OAuth2Fixture(NamedTuple):
|
|
18
|
+
"""The parameters needed to generate an OAuth2 client-credentials fixture.
|
|
19
|
+
|
|
20
|
+
Exactly one of ``token_url`` (a direct clientCredentials token endpoint
|
|
21
|
+
from the spec) or ``discovery_url`` (an OpenID Connect discovery
|
|
22
|
+
endpoint, resolved at runtime) is set. The client credentials and
|
|
23
|
+
optional scope come from the configuration. The generated test fetches
|
|
24
|
+
a token at runtime rather than embedding one.
|
|
25
|
+
"""
|
|
26
|
+
|
|
27
|
+
token_url: str | None = None
|
|
28
|
+
client_id: str = ""
|
|
29
|
+
client_secret: str = ""
|
|
30
|
+
discovery_url: str | None = None
|
|
31
|
+
scope: str | None = None
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ResolvedAuth(NamedTuple):
|
|
35
|
+
"""The outcome of resolving auth for one endpoint.
|
|
36
|
+
|
|
37
|
+
``headers``, ``query_params`` and ``cookies`` hold the static
|
|
38
|
+
authentication values to inject, by location (each empty when it does
|
|
39
|
+
not apply). ``oauth2`` carries the parameters for a runtime
|
|
40
|
+
token-fetching fixture, or None. ``warning`` is a human-readable
|
|
41
|
+
message when a declared scheme was skipped, or None otherwise.
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
headers: dict = {}
|
|
45
|
+
query_params: dict = {}
|
|
46
|
+
cookies: dict = {}
|
|
47
|
+
oauth2: OAuth2Fixture | None = None
|
|
48
|
+
manual_auth: str | None = None
|
|
49
|
+
warning: str | None = None
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_NO_AUTH = ResolvedAuth(headers={}, warning=None)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def resolve_auth(endpoint, security_schemes, auth_config):
|
|
56
|
+
"""Resolve authentication for a single endpoint.
|
|
57
|
+
|
|
58
|
+
The endpoint's security is a list of alternatives (OR); each
|
|
59
|
+
alternative is a list of scheme names that must all be satisfied
|
|
60
|
+
together (AND). The first alternative whose schemes are all
|
|
61
|
+
resolvable is used, merging every scheme's contribution. If no
|
|
62
|
+
alternative is fully satisfiable, generation proceeds without
|
|
63
|
+
authentication and a warning is returned.
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
endpoint: The Endpoint whose security requirement is resolved.
|
|
67
|
+
security_schemes: The specification's securitySchemes definitions.
|
|
68
|
+
auth_config: The AuthConfig carrying user-supplied credentials.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
A ResolvedAuth with the values to inject and an optional warning.
|
|
72
|
+
"""
|
|
73
|
+
if not endpoint.security:
|
|
74
|
+
return _NO_AUTH
|
|
75
|
+
|
|
76
|
+
for alternative in endpoint.security:
|
|
77
|
+
merged = _resolve_alternative(alternative, security_schemes, auth_config)
|
|
78
|
+
if merged is not None:
|
|
79
|
+
if merged.manual_auth is not None and merged.warning is None:
|
|
80
|
+
merged = merged._replace(
|
|
81
|
+
warning=(
|
|
82
|
+
f"Scheme '{merged.manual_auth}' uses an interactive "
|
|
83
|
+
f"OAuth2 flow that cannot be automated; a placeholder "
|
|
84
|
+
f"token is generated for you to complete manually."
|
|
85
|
+
)
|
|
86
|
+
)
|
|
87
|
+
return merged
|
|
88
|
+
|
|
89
|
+
scheme_names = sorted(
|
|
90
|
+
{name for alternative in endpoint.security for name in alternative}
|
|
91
|
+
)
|
|
92
|
+
listed = ", ".join(scheme_names) if scheme_names else "the declared schemes"
|
|
93
|
+
return ResolvedAuth(
|
|
94
|
+
warning=(
|
|
95
|
+
f"Could not satisfy security requirement(s) [{listed}] with the "
|
|
96
|
+
f"configured credentials; affected endpoints are generated "
|
|
97
|
+
f"without authentication."
|
|
98
|
+
),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def _resolve_alternative(alternative, security_schemes, auth_config):
|
|
103
|
+
"""Resolve one AND group, merging all schemes, or None if unsatisfiable.
|
|
104
|
+
|
|
105
|
+
An empty alternative (an empty security requirement in the spec) means
|
|
106
|
+
authentication is optional and resolves to no auth.
|
|
107
|
+
"""
|
|
108
|
+
headers = {}
|
|
109
|
+
query_params = {}
|
|
110
|
+
cookies = {}
|
|
111
|
+
oauth2 = None
|
|
112
|
+
manual_auth = None
|
|
113
|
+
for scheme_name in alternative:
|
|
114
|
+
contribution = _resolve_scheme(
|
|
115
|
+
scheme_name, security_schemes, auth_config
|
|
116
|
+
)
|
|
117
|
+
if contribution is None:
|
|
118
|
+
return None
|
|
119
|
+
(
|
|
120
|
+
scheme_headers,
|
|
121
|
+
scheme_query,
|
|
122
|
+
scheme_cookies,
|
|
123
|
+
scheme_oauth2,
|
|
124
|
+
scheme_manual,
|
|
125
|
+
) = contribution
|
|
126
|
+
headers.update(scheme_headers)
|
|
127
|
+
query_params.update(scheme_query)
|
|
128
|
+
cookies.update(scheme_cookies)
|
|
129
|
+
if scheme_oauth2 is not None:
|
|
130
|
+
oauth2 = scheme_oauth2
|
|
131
|
+
if scheme_manual is not None:
|
|
132
|
+
manual_auth = scheme_manual
|
|
133
|
+
|
|
134
|
+
return ResolvedAuth(
|
|
135
|
+
headers=headers,
|
|
136
|
+
query_params=query_params,
|
|
137
|
+
cookies=cookies,
|
|
138
|
+
oauth2=oauth2,
|
|
139
|
+
manual_auth=manual_auth,
|
|
140
|
+
)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _resolve_scheme(scheme_name, security_schemes, auth_config):
|
|
144
|
+
"""Resolve one scheme to a (headers, query_params, cookies, oauth2) tuple.
|
|
145
|
+
|
|
146
|
+
Returns None when the scheme is undefined, unsupported, or its
|
|
147
|
+
credentials are not configured - signalling that the enclosing
|
|
148
|
+
alternative cannot be satisfied.
|
|
149
|
+
"""
|
|
150
|
+
scheme = security_schemes.get(scheme_name)
|
|
151
|
+
if scheme is None:
|
|
152
|
+
return None
|
|
153
|
+
|
|
154
|
+
scheme_type = scheme.get("type")
|
|
155
|
+
|
|
156
|
+
if scheme_type == "oauth2":
|
|
157
|
+
return _resolve_oauth2(scheme_name, scheme, auth_config)
|
|
158
|
+
if scheme_type == "openIdConnect":
|
|
159
|
+
return _resolve_oidc(scheme, auth_config)
|
|
160
|
+
if scheme_type == "http":
|
|
161
|
+
http_scheme = scheme.get("scheme", "").lower()
|
|
162
|
+
if http_scheme == "bearer":
|
|
163
|
+
return _resolve_bearer(auth_config)
|
|
164
|
+
if http_scheme == "basic":
|
|
165
|
+
return _resolve_basic(auth_config)
|
|
166
|
+
if scheme_type == "apiKey" and scheme.get("in") in (
|
|
167
|
+
"header",
|
|
168
|
+
"query",
|
|
169
|
+
"cookie",
|
|
170
|
+
):
|
|
171
|
+
return _resolve_api_key(scheme, auth_config)
|
|
172
|
+
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _resolve_bearer(auth_config):
|
|
177
|
+
"""Return a bearer Authorization header, or None if unconfigured."""
|
|
178
|
+
bearer = auth_config.bearer
|
|
179
|
+
if bearer is None or not bearer.token:
|
|
180
|
+
return None
|
|
181
|
+
return ({"Authorization": f"Bearer {bearer.token}"}, {}, {}, None, None)
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _resolve_basic(auth_config):
|
|
185
|
+
"""Return a basic Authorization header, or None if unconfigured."""
|
|
186
|
+
basic = auth_config.basic
|
|
187
|
+
if basic is None or not basic.username or not basic.password:
|
|
188
|
+
return None
|
|
189
|
+
raw = f"{basic.username}:{basic.password}".encode("utf-8")
|
|
190
|
+
encoded = base64.b64encode(raw).decode("ascii")
|
|
191
|
+
return ({"Authorization": f"Basic {encoded}"}, {}, {}, None, None)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _resolve_api_key(scheme, auth_config):
|
|
195
|
+
"""Return an API-key contribution by location, or None if unconfigured.
|
|
196
|
+
|
|
197
|
+
The key's name and location come from the spec scheme; the config
|
|
198
|
+
supplies only the value.
|
|
199
|
+
"""
|
|
200
|
+
api_key = auth_config.api_key
|
|
201
|
+
if api_key is None or not api_key.value:
|
|
202
|
+
return None
|
|
203
|
+
key_name = scheme.get("name")
|
|
204
|
+
if not key_name:
|
|
205
|
+
return None
|
|
206
|
+
|
|
207
|
+
location = scheme.get("in")
|
|
208
|
+
if location == "query":
|
|
209
|
+
return ({}, {key_name: api_key.value}, {}, None, None)
|
|
210
|
+
if location == "cookie":
|
|
211
|
+
return ({}, {}, {key_name: api_key.value}, None, None)
|
|
212
|
+
return ({key_name: api_key.value}, {}, {}, None, None)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def _resolve_oauth2(scheme_name, scheme, auth_config):
|
|
216
|
+
"""Resolve an oauth2 scheme.
|
|
217
|
+
|
|
218
|
+
The clientCredentials flow is fully automated (it needs the spec's
|
|
219
|
+
tokenUrl and the configured client id and secret). When only an
|
|
220
|
+
interactive flow such as authorizationCode is offered, it cannot be
|
|
221
|
+
automated, so a manual stub naming the scheme is produced for the user
|
|
222
|
+
to complete. Purely interactive-only flows with no authorizationCode
|
|
223
|
+
(implicit, password) are not supported and make the alternative
|
|
224
|
+
unsatisfiable.
|
|
225
|
+
"""
|
|
226
|
+
flows = scheme.get("flows", {})
|
|
227
|
+
|
|
228
|
+
client_credentials = flows.get("clientCredentials")
|
|
229
|
+
if client_credentials is not None:
|
|
230
|
+
token_url = client_credentials.get("tokenUrl")
|
|
231
|
+
oauth2 = auth_config.oauth2
|
|
232
|
+
if (
|
|
233
|
+
token_url
|
|
234
|
+
and oauth2 is not None
|
|
235
|
+
and oauth2.client_id
|
|
236
|
+
and oauth2.client_secret
|
|
237
|
+
):
|
|
238
|
+
fixture = OAuth2Fixture(
|
|
239
|
+
token_url=token_url,
|
|
240
|
+
client_id=oauth2.client_id,
|
|
241
|
+
client_secret=oauth2.client_secret,
|
|
242
|
+
scope=oauth2.scope,
|
|
243
|
+
)
|
|
244
|
+
return ({}, {}, {}, fixture, None)
|
|
245
|
+
|
|
246
|
+
if "authorizationCode" in flows:
|
|
247
|
+
return ({}, {}, {}, None, scheme_name)
|
|
248
|
+
|
|
249
|
+
return None
|
|
250
|
+
|
|
251
|
+
|
|
252
|
+
def _resolve_oidc(scheme, auth_config):
|
|
253
|
+
"""Resolve an openIdConnect scheme to a discovery-based OAuth2 fixture.
|
|
254
|
+
|
|
255
|
+
The generated test performs OpenID Connect discovery at runtime using
|
|
256
|
+
the scheme's openIdConnectUrl, then obtains a token via the client
|
|
257
|
+
credentials grant. It therefore needs configured client credentials;
|
|
258
|
+
without them the scheme is not satisfiable.
|
|
259
|
+
"""
|
|
260
|
+
discovery_url = scheme.get("openIdConnectUrl")
|
|
261
|
+
if not discovery_url:
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
oauth2 = auth_config.oauth2
|
|
265
|
+
if oauth2 is None or not oauth2.client_id or not oauth2.client_secret:
|
|
266
|
+
return None
|
|
267
|
+
|
|
268
|
+
fixture = OAuth2Fixture(
|
|
269
|
+
discovery_url=discovery_url,
|
|
270
|
+
client_id=oauth2.client_id,
|
|
271
|
+
client_secret=oauth2.client_secret,
|
|
272
|
+
scope=oauth2.scope,
|
|
273
|
+
)
|
|
274
|
+
return ({}, {}, {}, fixture, None)
|
|
@@ -48,12 +48,24 @@ class BasicAuth:
|
|
|
48
48
|
password: str | None = None
|
|
49
49
|
|
|
50
50
|
|
|
51
|
+
@dataclass
|
|
52
|
+
class OAuth2Auth:
|
|
53
|
+
"""OAuth2 client-credentials: client id, secret, and optional scope.
|
|
54
|
+
|
|
55
|
+
The token endpoint URL comes from the specification, not the config.
|
|
56
|
+
"""
|
|
57
|
+
client_id: str | None = None
|
|
58
|
+
client_secret: str | None = None
|
|
59
|
+
scope: str | None = None
|
|
60
|
+
|
|
61
|
+
|
|
51
62
|
@dataclass
|
|
52
63
|
class AuthConfig:
|
|
53
64
|
"""Authentication credentials, keyed by scheme kind. All optional."""
|
|
54
65
|
bearer: BearerAuth | None = None
|
|
55
66
|
api_key: ApiKeyAuth | None = None
|
|
56
67
|
basic: BasicAuth | None = None
|
|
68
|
+
oauth2: OAuth2Auth | None = None
|
|
57
69
|
|
|
58
70
|
|
|
59
71
|
@dataclass
|
|
@@ -115,6 +127,7 @@ _AUTH_SCHEMES = {
|
|
|
115
127
|
"bearer": BearerAuth,
|
|
116
128
|
"api_key": ApiKeyAuth,
|
|
117
129
|
"basic": BasicAuth,
|
|
130
|
+
"oauth2": OAuth2Auth,
|
|
118
131
|
}
|
|
119
132
|
|
|
120
133
|
|
|
@@ -30,7 +30,7 @@ class Endpoint:
|
|
|
30
30
|
request_body: dict | None = None
|
|
31
31
|
responses: list[str] = field(default_factory=list)
|
|
32
32
|
response_schema: dict | None = None
|
|
33
|
-
security: list[str] = field(default_factory=list)
|
|
33
|
+
security: list[list[str]] = field(default_factory=list)
|
|
34
34
|
|
|
35
35
|
|
|
36
36
|
@dataclass
|
|
@@ -61,15 +61,36 @@ def write_test_files(
|
|
|
61
61
|
warnings = []
|
|
62
62
|
for tag in sorted(grouped):
|
|
63
63
|
group = grouped[tag]
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
64
|
+
|
|
65
|
+
# The session carries the first endpoint's auth (the base); any
|
|
66
|
+
# scenario whose endpoint resolves to different auth overrides it
|
|
67
|
+
# per call.
|
|
68
|
+
base = resolve_auth(group[0].endpoint, security_schemes, auth_config)
|
|
69
|
+
overrides = []
|
|
70
|
+
for scenario in group:
|
|
71
|
+
resolved = resolve_auth(
|
|
72
|
+
scenario.endpoint, security_schemes, auth_config
|
|
73
|
+
)
|
|
74
|
+
if resolved.warning and resolved.warning not in warnings:
|
|
75
|
+
warnings.append(resolved.warning)
|
|
76
|
+
if _same_auth(resolved, base):
|
|
77
|
+
overrides.append(None)
|
|
78
|
+
else:
|
|
79
|
+
overrides.append(resolved)
|
|
68
80
|
|
|
69
81
|
filename = f"test_{_sanitize(tag)}.py"
|
|
70
82
|
file_path = output_path / filename
|
|
71
83
|
content = render_test_file(
|
|
72
|
-
filename,
|
|
84
|
+
filename,
|
|
85
|
+
base_url,
|
|
86
|
+
group,
|
|
87
|
+
timeout,
|
|
88
|
+
base.headers,
|
|
89
|
+
base.query_params,
|
|
90
|
+
base.cookies,
|
|
91
|
+
overrides,
|
|
92
|
+
base.oauth2,
|
|
93
|
+
base.manual_auth,
|
|
73
94
|
)
|
|
74
95
|
file_path.write_text(content, encoding="utf-8")
|
|
75
96
|
written.append(str(file_path))
|
|
@@ -77,6 +98,17 @@ def write_test_files(
|
|
|
77
98
|
return WriteResult(written=written, warnings=warnings)
|
|
78
99
|
|
|
79
100
|
|
|
101
|
+
def _same_auth(a, b):
|
|
102
|
+
"""True when two ResolvedAuth carry identical injected values."""
|
|
103
|
+
return (
|
|
104
|
+
a.headers == b.headers
|
|
105
|
+
and a.query_params == b.query_params
|
|
106
|
+
and a.cookies == b.cookies
|
|
107
|
+
and a.oauth2 == b.oauth2
|
|
108
|
+
and a.manual_auth == b.manual_auth
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
|
|
80
112
|
def _group_by_tag(scenarios):
|
|
81
113
|
"""Group scenarios into a dict keyed by their endpoint's tag."""
|
|
82
114
|
grouped = {}
|