silentparams 0.1.0__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.
- silentparams-0.1.0/LICENSE +21 -0
- silentparams-0.1.0/PKG-INFO +111 -0
- silentparams-0.1.0/README.md +90 -0
- silentparams-0.1.0/pyproject.toml +41 -0
- silentparams-0.1.0/setup.cfg +4 -0
- silentparams-0.1.0/src/silentparams/__init__.py +2 -0
- silentparams-0.1.0/src/silentparams/__main__.py +5 -0
- silentparams-0.1.0/src/silentparams/cli.py +76 -0
- silentparams-0.1.0/src/silentparams/core.py +232 -0
- silentparams-0.1.0/src/silentparams.egg-info/PKG-INFO +111 -0
- silentparams-0.1.0/src/silentparams.egg-info/SOURCES.txt +13 -0
- silentparams-0.1.0/src/silentparams.egg-info/dependency_links.txt +1 -0
- silentparams-0.1.0/src/silentparams.egg-info/entry_points.txt +2 -0
- silentparams-0.1.0/src/silentparams.egg-info/top_level.txt +1 -0
- silentparams-0.1.0/tests/test_probe.py +117 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 novasdiego1
|
|
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,111 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: silentparams
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Find HTTP APIs that silently ignore misspelled query parameters
|
|
5
|
+
Author: novasdiego1
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/novasdiego1/silentparams
|
|
8
|
+
Project-URL: Issues, https://github.com/novasdiego1/silentparams/issues
|
|
9
|
+
Keywords: api,http,testing,query parameters,silent failures,ci
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
16
|
+
Classifier: Topic :: Software Development :: Testing
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# silentparams
|
|
23
|
+
|
|
24
|
+
Find HTTP APIs that silently ignore misspelled query parameters.
|
|
25
|
+
|
|
26
|
+
You call `GET /issues?states=closed`. The API only knows `state`. It does not
|
|
27
|
+
answer "unknown parameter": it answers **200 with every open issue**, and your
|
|
28
|
+
code (or your agent) believes it filtered. Nothing failed, so nothing alerts.
|
|
29
|
+
|
|
30
|
+
`silentparams` sends a handful of read-only requests with plausible misspellings
|
|
31
|
+
of each parameter in your URL and tells you, per name, whether the API rejects
|
|
32
|
+
it, accepts it as an alias, or ignores it without a word.
|
|
33
|
+
|
|
34
|
+
```console
|
|
35
|
+
$ silentparams "https://api.github.com/repos/python/cpython/issues?state=closed&per_page=3"
|
|
36
|
+
Parameter 'state=closed': removing it changes the response
|
|
37
|
+
states HTTP 200 IGNORED silently (2xx, same as without the parameter)
|
|
38
|
+
STATE HTTP 200 IGNORED silently (2xx, same as without the parameter)
|
|
39
|
+
zzb22fbb9f HTTP 200 IGNORED silently (2xx, same as without the parameter)
|
|
40
|
+
...
|
|
41
|
+
RESULT: this API silently ignores unknown parameter names. A typo in a filter returns unfiltered data with HTTP 200.
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
That is not a bug in GitHub. Ignoring unknown parameters is a common design
|
|
45
|
+
choice. It just means **a typo in your client is invisible**, so you should know
|
|
46
|
+
which APIs behave this way before you trust a filtered result.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```console
|
|
51
|
+
pip install silentparams # no dependencies, Python 3.10+
|
|
52
|
+
# or the latest code: pip install git+https://github.com/novasdiego1/silentparams
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## How it works
|
|
56
|
+
|
|
57
|
+
For each query parameter `p` in the URL:
|
|
58
|
+
|
|
59
|
+
| Request | Purpose |
|
|
60
|
+
|---|---|
|
|
61
|
+
| the URL as given, twice | baseline, and a check that the response is stable |
|
|
62
|
+
| the URL without `p` | what the API returns when `p` is absent |
|
|
63
|
+
| `p` renamed (plural/singular, trailing `_`, case, `snake`/`kebab`/`camel`, doubled letter) and one nonsense name | the decoys |
|
|
64
|
+
|
|
65
|
+
Each decoy is classified by comparing normalized response bodies (JSON is
|
|
66
|
+
canonicalized, so key order does not matter):
|
|
67
|
+
|
|
68
|
+
| Verdict | Meaning |
|
|
69
|
+
|---|---|
|
|
70
|
+
| `IGNORED silently` | 2xx and the same response as without `p`: the name was dropped without a word |
|
|
71
|
+
| `rejected` | 4xx: the API refuses unknown names (the safe behaviour) |
|
|
72
|
+
| `alias` | 2xx and the same response as the baseline: the variant is accepted |
|
|
73
|
+
| `different` | 2xx with some other response: inconclusive |
|
|
74
|
+
|
|
75
|
+
It also tells you when removing `p` changes nothing, which usually means `p`
|
|
76
|
+
itself is being ignored.
|
|
77
|
+
|
|
78
|
+
## Options
|
|
79
|
+
|
|
80
|
+
```text
|
|
81
|
+
-H 'Name: value' extra header (repeatable), e.g. an Authorization token
|
|
82
|
+
--ignore-key KEY JSON key to ignore when comparing (timestamps, request ids)
|
|
83
|
+
--decoys N decoy names per parameter (default 6)
|
|
84
|
+
--max-requests N hard cap on requests sent (default 60)
|
|
85
|
+
--delay S seconds between requests (default 0.5)
|
|
86
|
+
--json machine-readable report
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Exit code: `1` if the API silently ignores unknown names, `0` if not, `2` on
|
|
90
|
+
usage errors. That makes it usable as a CI check for an API you own.
|
|
91
|
+
|
|
92
|
+
If the same request returns different bodies (a timestamp, a request id), the
|
|
93
|
+
report warns that results are unreliable: add `--ignore-key` for those fields.
|
|
94
|
+
|
|
95
|
+
## Be polite
|
|
96
|
+
|
|
97
|
+
It only sends `GET` requests, waits between them, and stops at
|
|
98
|
+
`--max-requests`. Point it at APIs you are allowed to query, and mind their
|
|
99
|
+
rate limits.
|
|
100
|
+
|
|
101
|
+
## Why this exists
|
|
102
|
+
|
|
103
|
+
An auditor reported reaching a wrong conclusion this way. They tried four
|
|
104
|
+
spellings of a per-author filter (`author`, `agent`, `agent_id`, `pubkey`) on an
|
|
105
|
+
API that only declared `authors`. Every request returned 200 with unfiltered
|
|
106
|
+
rows, so they wrote that no such filter existed. The same API rejected a bad
|
|
107
|
+
`limit` value with 422: it checked values carefully and never checked names.
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
MIT
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
# silentparams
|
|
2
|
+
|
|
3
|
+
Find HTTP APIs that silently ignore misspelled query parameters.
|
|
4
|
+
|
|
5
|
+
You call `GET /issues?states=closed`. The API only knows `state`. It does not
|
|
6
|
+
answer "unknown parameter": it answers **200 with every open issue**, and your
|
|
7
|
+
code (or your agent) believes it filtered. Nothing failed, so nothing alerts.
|
|
8
|
+
|
|
9
|
+
`silentparams` sends a handful of read-only requests with plausible misspellings
|
|
10
|
+
of each parameter in your URL and tells you, per name, whether the API rejects
|
|
11
|
+
it, accepts it as an alias, or ignores it without a word.
|
|
12
|
+
|
|
13
|
+
```console
|
|
14
|
+
$ silentparams "https://api.github.com/repos/python/cpython/issues?state=closed&per_page=3"
|
|
15
|
+
Parameter 'state=closed': removing it changes the response
|
|
16
|
+
states HTTP 200 IGNORED silently (2xx, same as without the parameter)
|
|
17
|
+
STATE HTTP 200 IGNORED silently (2xx, same as without the parameter)
|
|
18
|
+
zzb22fbb9f HTTP 200 IGNORED silently (2xx, same as without the parameter)
|
|
19
|
+
...
|
|
20
|
+
RESULT: this API silently ignores unknown parameter names. A typo in a filter returns unfiltered data with HTTP 200.
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
That is not a bug in GitHub. Ignoring unknown parameters is a common design
|
|
24
|
+
choice. It just means **a typo in your client is invisible**, so you should know
|
|
25
|
+
which APIs behave this way before you trust a filtered result.
|
|
26
|
+
|
|
27
|
+
## Install
|
|
28
|
+
|
|
29
|
+
```console
|
|
30
|
+
pip install silentparams # no dependencies, Python 3.10+
|
|
31
|
+
# or the latest code: pip install git+https://github.com/novasdiego1/silentparams
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
## How it works
|
|
35
|
+
|
|
36
|
+
For each query parameter `p` in the URL:
|
|
37
|
+
|
|
38
|
+
| Request | Purpose |
|
|
39
|
+
|---|---|
|
|
40
|
+
| the URL as given, twice | baseline, and a check that the response is stable |
|
|
41
|
+
| the URL without `p` | what the API returns when `p` is absent |
|
|
42
|
+
| `p` renamed (plural/singular, trailing `_`, case, `snake`/`kebab`/`camel`, doubled letter) and one nonsense name | the decoys |
|
|
43
|
+
|
|
44
|
+
Each decoy is classified by comparing normalized response bodies (JSON is
|
|
45
|
+
canonicalized, so key order does not matter):
|
|
46
|
+
|
|
47
|
+
| Verdict | Meaning |
|
|
48
|
+
|---|---|
|
|
49
|
+
| `IGNORED silently` | 2xx and the same response as without `p`: the name was dropped without a word |
|
|
50
|
+
| `rejected` | 4xx: the API refuses unknown names (the safe behaviour) |
|
|
51
|
+
| `alias` | 2xx and the same response as the baseline: the variant is accepted |
|
|
52
|
+
| `different` | 2xx with some other response: inconclusive |
|
|
53
|
+
|
|
54
|
+
It also tells you when removing `p` changes nothing, which usually means `p`
|
|
55
|
+
itself is being ignored.
|
|
56
|
+
|
|
57
|
+
## Options
|
|
58
|
+
|
|
59
|
+
```text
|
|
60
|
+
-H 'Name: value' extra header (repeatable), e.g. an Authorization token
|
|
61
|
+
--ignore-key KEY JSON key to ignore when comparing (timestamps, request ids)
|
|
62
|
+
--decoys N decoy names per parameter (default 6)
|
|
63
|
+
--max-requests N hard cap on requests sent (default 60)
|
|
64
|
+
--delay S seconds between requests (default 0.5)
|
|
65
|
+
--json machine-readable report
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
Exit code: `1` if the API silently ignores unknown names, `0` if not, `2` on
|
|
69
|
+
usage errors. That makes it usable as a CI check for an API you own.
|
|
70
|
+
|
|
71
|
+
If the same request returns different bodies (a timestamp, a request id), the
|
|
72
|
+
report warns that results are unreliable: add `--ignore-key` for those fields.
|
|
73
|
+
|
|
74
|
+
## Be polite
|
|
75
|
+
|
|
76
|
+
It only sends `GET` requests, waits between them, and stops at
|
|
77
|
+
`--max-requests`. Point it at APIs you are allowed to query, and mind their
|
|
78
|
+
rate limits.
|
|
79
|
+
|
|
80
|
+
## Why this exists
|
|
81
|
+
|
|
82
|
+
An auditor reported reaching a wrong conclusion this way. They tried four
|
|
83
|
+
spellings of a per-author filter (`author`, `agent`, `agent_id`, `pubkey`) on an
|
|
84
|
+
API that only declared `authors`. Every request returned 200 with unfiltered
|
|
85
|
+
rows, so they wrote that no such filter existed. The same API rejected a bad
|
|
86
|
+
`limit` value with 422: it checked values carefully and never checked names.
|
|
87
|
+
|
|
88
|
+
## License
|
|
89
|
+
|
|
90
|
+
MIT
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=69"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "silentparams"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Find HTTP APIs that silently ignore misspelled query parameters"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.10"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [{name = "novasdiego1"}]
|
|
13
|
+
keywords = ["api", "http", "testing", "query parameters", "silent failures", "ci"]
|
|
14
|
+
classifiers = [
|
|
15
|
+
"Development Status :: 3 - Alpha",
|
|
16
|
+
"Environment :: Console",
|
|
17
|
+
"Intended Audience :: Developers",
|
|
18
|
+
"License :: OSI Approved :: MIT License",
|
|
19
|
+
"Programming Language :: Python :: 3",
|
|
20
|
+
"Topic :: Internet :: WWW/HTTP",
|
|
21
|
+
"Topic :: Software Development :: Testing",
|
|
22
|
+
]
|
|
23
|
+
dependencies = []
|
|
24
|
+
|
|
25
|
+
[project.urls]
|
|
26
|
+
Homepage = "https://github.com/novasdiego1/silentparams"
|
|
27
|
+
Issues = "https://github.com/novasdiego1/silentparams/issues"
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
silentparams = "silentparams.cli:main"
|
|
31
|
+
|
|
32
|
+
[tool.pytest.ini_options]
|
|
33
|
+
pythonpath = ["src"]
|
|
34
|
+
testpaths = ["tests"]
|
|
35
|
+
|
|
36
|
+
[tool.ruff]
|
|
37
|
+
line-length = 120
|
|
38
|
+
target-version = "py310"
|
|
39
|
+
|
|
40
|
+
[tool.ruff.lint]
|
|
41
|
+
select = ["E", "F", "W", "I", "UP", "B", "SIM"]
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Command line: silentparams URL [-H 'Name: value'] [--json]."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import sys
|
|
7
|
+
from dataclasses import asdict
|
|
8
|
+
|
|
9
|
+
from silentparams import __version__
|
|
10
|
+
from silentparams.core import Report, http_fetch, probe
|
|
11
|
+
|
|
12
|
+
VERDICT_TEXT = {
|
|
13
|
+
"ignored": "IGNORED silently (2xx, same as without the parameter)",
|
|
14
|
+
"rejected": "rejected (4xx)",
|
|
15
|
+
"alias": "accepted as an alias",
|
|
16
|
+
"different": "2xx, different response (inconclusive)",
|
|
17
|
+
"error": "server error or no response",
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def render(report: Report) -> str:
|
|
22
|
+
lines = [f"URL: {report.url}", f"Baseline: HTTP {report.baseline.status}, {report.baseline.size} bytes"
|
|
23
|
+
+ (f", {report.baseline.items} items" if report.baseline.items is not None else ""),
|
|
24
|
+
f"Requests sent: {report.requests}"]
|
|
25
|
+
if not report.stable:
|
|
26
|
+
lines.append("WARNING: the same request gave two different responses. Results are unreliable; "
|
|
27
|
+
"use --ignore-key for volatile fields (timestamps, request ids).")
|
|
28
|
+
for p in report.params:
|
|
29
|
+
effect = "changes the response" if p.effective else "does NOT change the response (ignored or no-op?)"
|
|
30
|
+
lines += ["", f"Parameter '{p.name}={p.value}': removing it {effect}"]
|
|
31
|
+
for d in p.decoys:
|
|
32
|
+
lines.append(f" {d.name:<24} HTTP {d.response.status:<4} {VERDICT_TEXT[d.verdict]}")
|
|
33
|
+
verdict = report.ignores_unknown_names
|
|
34
|
+
lines.append("")
|
|
35
|
+
if verdict is True:
|
|
36
|
+
lines.append("RESULT: this API silently ignores unknown parameter names. A typo in a filter returns "
|
|
37
|
+
"unfiltered data with HTTP 200.")
|
|
38
|
+
elif verdict is False:
|
|
39
|
+
lines.append("RESULT: this API rejects unknown parameter names. Typos fail loudly.")
|
|
40
|
+
else:
|
|
41
|
+
lines.append("RESULT: inconclusive. See the per-decoy lines above.")
|
|
42
|
+
return "\n".join(lines)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def main(argv: list[str] | None = None) -> int:
|
|
46
|
+
ap = argparse.ArgumentParser(prog="silentparams", description=__doc__)
|
|
47
|
+
ap.add_argument("url", help="a GET URL with at least one query parameter")
|
|
48
|
+
ap.add_argument("-H", "--header", action="append", default=[], help="extra header, 'Name: value'")
|
|
49
|
+
ap.add_argument("--decoys", type=int, default=6, help="decoy names per parameter (default 6)")
|
|
50
|
+
ap.add_argument("--max-requests", type=int, default=60, help="hard cap on requests sent (default 60)")
|
|
51
|
+
ap.add_argument("--delay", type=float, default=0.5, help="seconds between requests (default 0.5)")
|
|
52
|
+
ap.add_argument("--ignore-key", action="append", default=[], help="JSON key to ignore when comparing")
|
|
53
|
+
ap.add_argument("--json", action="store_true", help="machine-readable output")
|
|
54
|
+
ap.add_argument("--version", action="version", version=__version__)
|
|
55
|
+
args = ap.parse_args(argv)
|
|
56
|
+
headers = {}
|
|
57
|
+
for h in args.header:
|
|
58
|
+
name, sep, value = h.partition(":")
|
|
59
|
+
if not sep:
|
|
60
|
+
ap.error(f"bad header {h!r}; use 'Name: value'")
|
|
61
|
+
headers[name.strip()] = value.strip()
|
|
62
|
+
try:
|
|
63
|
+
report = probe(args.url, http_fetch(headers), decoys_per_param=args.decoys,
|
|
64
|
+
max_requests=args.max_requests, delay=args.delay, ignore_keys=args.ignore_key)
|
|
65
|
+
except (ValueError, RuntimeError) as exc:
|
|
66
|
+
print(f"silentparams: {exc}", file=sys.stderr)
|
|
67
|
+
return 2
|
|
68
|
+
if args.json:
|
|
69
|
+
print(json.dumps({**asdict(report), "ignores_unknown_names": report.ignores_unknown_names}, indent=2))
|
|
70
|
+
else:
|
|
71
|
+
print(render(report))
|
|
72
|
+
return 1 if report.ignores_unknown_names else 0
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
if __name__ == "__main__":
|
|
76
|
+
sys.exit(main())
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Probe an HTTP API for query parameters it silently ignores.
|
|
2
|
+
|
|
3
|
+
The failure this catches: a client sends `?author=alice` to an API that only
|
|
4
|
+
knows `authors`. Instead of rejecting the unknown name, the API answers 200
|
|
5
|
+
with the unfiltered result, and the client believes it filtered.
|
|
6
|
+
|
|
7
|
+
Method, per query parameter in the URL you give:
|
|
8
|
+
|
|
9
|
+
1. baseline - the URL as given.
|
|
10
|
+
2. without - the same URL with that parameter removed.
|
|
11
|
+
3. decoys - the parameter renamed (typos, plural/singular, case, separators)
|
|
12
|
+
and one nonsense name, each with the same value.
|
|
13
|
+
|
|
14
|
+
A decoy that answers 2xx with the same response as `without` was ignored
|
|
15
|
+
silently. A decoy answered with 4xx was rejected (the safe behaviour). A decoy
|
|
16
|
+
that answers like `baseline` is an accepted alias.
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import hashlib
|
|
21
|
+
import json
|
|
22
|
+
import secrets
|
|
23
|
+
import time
|
|
24
|
+
import urllib.error
|
|
25
|
+
import urllib.request
|
|
26
|
+
from collections.abc import Callable, Iterable
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from urllib.parse import parse_qsl, urlencode, urlsplit, urlunsplit
|
|
29
|
+
|
|
30
|
+
USER_AGENT = "silentparams/0.1 (read-only API probe)"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass(frozen=True)
|
|
34
|
+
class Response:
|
|
35
|
+
status: int
|
|
36
|
+
fingerprint: str # hash of the normalized body
|
|
37
|
+
size: int
|
|
38
|
+
items: int | None = None # number of records, when the body is a JSON list or wraps one
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
@dataclass
|
|
42
|
+
class DecoyResult:
|
|
43
|
+
name: str
|
|
44
|
+
response: Response
|
|
45
|
+
verdict: str # "ignored", "rejected", "alias", "different", "error"
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass
|
|
49
|
+
class ParamReport:
|
|
50
|
+
name: str
|
|
51
|
+
value: str
|
|
52
|
+
effective: bool # removing it changes the response
|
|
53
|
+
without: Response
|
|
54
|
+
decoys: list[DecoyResult] = field(default_factory=list)
|
|
55
|
+
|
|
56
|
+
@property
|
|
57
|
+
def silently_ignored(self) -> list[str]:
|
|
58
|
+
return [d.name for d in self.decoys if d.verdict == "ignored"]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class Report:
|
|
63
|
+
url: str
|
|
64
|
+
baseline: Response
|
|
65
|
+
stable: bool # the same request twice gave the same response
|
|
66
|
+
params: list[ParamReport]
|
|
67
|
+
requests: int
|
|
68
|
+
|
|
69
|
+
@property
|
|
70
|
+
def ignores_unknown_names(self) -> bool | None:
|
|
71
|
+
"""True if any decoy was silently ignored, False if all were rejected, None if inconclusive."""
|
|
72
|
+
verdicts = [d.verdict for p in self.params for d in p.decoys]
|
|
73
|
+
if "ignored" in verdicts:
|
|
74
|
+
return True
|
|
75
|
+
if verdicts and all(v == "rejected" for v in verdicts):
|
|
76
|
+
return False
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
# --- Decoy names ------------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
def _split_words(name: str) -> list[str]:
|
|
83
|
+
out, word = [], ""
|
|
84
|
+
for ch in name:
|
|
85
|
+
if ch in "_-.":
|
|
86
|
+
if word:
|
|
87
|
+
out.append(word)
|
|
88
|
+
word = ""
|
|
89
|
+
elif ch.isupper() and word and not word[-1].isupper():
|
|
90
|
+
out.append(word)
|
|
91
|
+
word = ch
|
|
92
|
+
else:
|
|
93
|
+
word += ch
|
|
94
|
+
if word:
|
|
95
|
+
out.append(word)
|
|
96
|
+
return [w.lower() for w in out]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def decoy_names(name: str, limit: int = 6, rng: Callable[[int], str] = secrets.token_hex) -> list[str]:
|
|
100
|
+
"""Plausible mistakes for `name`, most likely first, plus one nonsense name. Never returns `name`."""
|
|
101
|
+
words = _split_words(name) or [name.lower()]
|
|
102
|
+
candidates = [
|
|
103
|
+
name[:-1] if name.endswith("s") and len(name) > 2 else name + "s", # authors <-> author
|
|
104
|
+
name + "_" if not name.endswith("_") else name.rstrip("_"),
|
|
105
|
+
name.upper() if name != name.upper() else name.lower(),
|
|
106
|
+
"_".join(words),
|
|
107
|
+
"-".join(words),
|
|
108
|
+
words[0] + "".join(w.capitalize() for w in words[1:]), # camelCase
|
|
109
|
+
name[:-1] + name[-1] * 2 if len(name) > 1 else name * 2, # doubled last letter
|
|
110
|
+
]
|
|
111
|
+
seen, out = {name}, []
|
|
112
|
+
for c in candidates:
|
|
113
|
+
if c and c not in seen:
|
|
114
|
+
seen.add(c)
|
|
115
|
+
out.append(c)
|
|
116
|
+
out = out[: max(limit - 1, 0)]
|
|
117
|
+
out.append("zz" + rng(4)) # nonsense: no API should know it
|
|
118
|
+
return out
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
# --- HTTP -------------------------------------------------------------------------------------
|
|
122
|
+
|
|
123
|
+
def normalize(body: bytes, content_type: str, ignore_keys: Iterable[str] = ()) -> tuple[str, int | None]:
|
|
124
|
+
"""Fingerprint a body. JSON is canonicalized and volatile keys dropped; anything else is hashed as is."""
|
|
125
|
+
ignore = set(ignore_keys)
|
|
126
|
+
items = None
|
|
127
|
+
if "json" in content_type or body[:1] in (b"{", b"["):
|
|
128
|
+
try:
|
|
129
|
+
data = json.loads(body)
|
|
130
|
+
except ValueError:
|
|
131
|
+
data = None
|
|
132
|
+
if data is not None:
|
|
133
|
+
def strip(x):
|
|
134
|
+
if isinstance(x, dict):
|
|
135
|
+
return {k: strip(v) for k, v in x.items() if k not in ignore}
|
|
136
|
+
if isinstance(x, list):
|
|
137
|
+
return [strip(v) for v in x]
|
|
138
|
+
return x
|
|
139
|
+
data = strip(data)
|
|
140
|
+
if isinstance(data, list):
|
|
141
|
+
items = len(data)
|
|
142
|
+
elif isinstance(data, dict):
|
|
143
|
+
lists = [v for v in data.values() if isinstance(v, list)]
|
|
144
|
+
if len(lists) == 1:
|
|
145
|
+
items = len(lists[0])
|
|
146
|
+
body = json.dumps(data, sort_keys=True, separators=(",", ":")).encode()
|
|
147
|
+
return hashlib.sha256(body).hexdigest()[:16], items
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
Fetcher = Callable[[str], tuple[int, bytes, str]]
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def http_fetch(headers: dict[str, str] | None = None, timeout: float = 20.0) -> Fetcher:
|
|
154
|
+
"""A GET-only fetcher. Returns (status, body, content-type); HTTP errors are answers, not exceptions."""
|
|
155
|
+
base = {"User-Agent": USER_AGENT, "Accept": "application/json, */*;q=0.5", **(headers or {})}
|
|
156
|
+
|
|
157
|
+
def fetch(url: str) -> tuple[int, bytes, str]:
|
|
158
|
+
req = urllib.request.Request(url, headers=base, method="GET")
|
|
159
|
+
try:
|
|
160
|
+
with urllib.request.urlopen(req, timeout=timeout) as r:
|
|
161
|
+
return r.status, r.read(), r.headers.get("Content-Type", "")
|
|
162
|
+
except urllib.error.HTTPError as e:
|
|
163
|
+
return e.code, e.read(), e.headers.get("Content-Type", "") if e.headers else ""
|
|
164
|
+
|
|
165
|
+
return fetch
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def with_params(url: str, params: list[tuple[str, str]]) -> str:
|
|
169
|
+
parts = urlsplit(url)
|
|
170
|
+
return urlunsplit(parts._replace(query=urlencode(params)))
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
# --- Probe ------------------------------------------------------------------------------------
|
|
174
|
+
|
|
175
|
+
def classify(decoy: Response, baseline: Response, without: Response) -> str:
|
|
176
|
+
if decoy.status >= 500 or decoy.status == 0:
|
|
177
|
+
return "error"
|
|
178
|
+
if 400 <= decoy.status < 500:
|
|
179
|
+
return "rejected"
|
|
180
|
+
same_as_without = decoy.fingerprint == without.fingerprint
|
|
181
|
+
same_as_baseline = decoy.fingerprint == baseline.fingerprint
|
|
182
|
+
if same_as_baseline and not same_as_without:
|
|
183
|
+
return "alias"
|
|
184
|
+
if same_as_without:
|
|
185
|
+
return "ignored"
|
|
186
|
+
return "different"
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def probe(
|
|
190
|
+
url: str,
|
|
191
|
+
fetch: Fetcher,
|
|
192
|
+
*,
|
|
193
|
+
decoys_per_param: int = 6,
|
|
194
|
+
max_requests: int = 60,
|
|
195
|
+
delay: float = 0.5,
|
|
196
|
+
ignore_keys: Iterable[str] = (),
|
|
197
|
+
rng: Callable[[int], str] = secrets.token_hex,
|
|
198
|
+
) -> Report:
|
|
199
|
+
params = parse_qsl(urlsplit(url).query, keep_blank_values=True)
|
|
200
|
+
if not params:
|
|
201
|
+
raise ValueError("the URL has no query parameters to probe")
|
|
202
|
+
ignore = list(ignore_keys)
|
|
203
|
+
count = 0
|
|
204
|
+
|
|
205
|
+
def get(u: str) -> Response:
|
|
206
|
+
nonlocal count
|
|
207
|
+
if count >= max_requests:
|
|
208
|
+
raise RuntimeError(f"request budget exhausted ({max_requests}); raise --max-requests")
|
|
209
|
+
if count and delay:
|
|
210
|
+
time.sleep(delay)
|
|
211
|
+
count += 1
|
|
212
|
+
try:
|
|
213
|
+
status, body, ctype = fetch(u)
|
|
214
|
+
except OSError:
|
|
215
|
+
return Response(0, "", 0)
|
|
216
|
+
fp, items = normalize(body, ctype, ignore)
|
|
217
|
+
return Response(status, fp, len(body), items)
|
|
218
|
+
|
|
219
|
+
baseline = get(url)
|
|
220
|
+
stable = get(url).fingerprint == baseline.fingerprint
|
|
221
|
+
reports = []
|
|
222
|
+
for i, (name, value) in enumerate(params):
|
|
223
|
+
rest = params[:i] + params[i + 1:]
|
|
224
|
+
without = get(with_params(url, rest))
|
|
225
|
+
report = ParamReport(name, value, effective=without.fingerprint != baseline.fingerprint, without=without)
|
|
226
|
+
for decoy in decoy_names(name, decoys_per_param, rng):
|
|
227
|
+
if any(decoy == n for n, _ in params):
|
|
228
|
+
continue
|
|
229
|
+
r = get(with_params(url, rest[:i] + [(decoy, value)] + rest[i:]))
|
|
230
|
+
report.decoys.append(DecoyResult(decoy, r, classify(r, baseline, without)))
|
|
231
|
+
reports.append(report)
|
|
232
|
+
return Report(url, baseline, stable, reports, count)
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: silentparams
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Find HTTP APIs that silently ignore misspelled query parameters
|
|
5
|
+
Author: novasdiego1
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/novasdiego1/silentparams
|
|
8
|
+
Project-URL: Issues, https://github.com/novasdiego1/silentparams/issues
|
|
9
|
+
Keywords: api,http,testing,query parameters,silent failures,ci
|
|
10
|
+
Classifier: Development Status :: 3 - Alpha
|
|
11
|
+
Classifier: Environment :: Console
|
|
12
|
+
Classifier: Intended Audience :: Developers
|
|
13
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
14
|
+
Classifier: Programming Language :: Python :: 3
|
|
15
|
+
Classifier: Topic :: Internet :: WWW/HTTP
|
|
16
|
+
Classifier: Topic :: Software Development :: Testing
|
|
17
|
+
Requires-Python: >=3.10
|
|
18
|
+
Description-Content-Type: text/markdown
|
|
19
|
+
License-File: LICENSE
|
|
20
|
+
Dynamic: license-file
|
|
21
|
+
|
|
22
|
+
# silentparams
|
|
23
|
+
|
|
24
|
+
Find HTTP APIs that silently ignore misspelled query parameters.
|
|
25
|
+
|
|
26
|
+
You call `GET /issues?states=closed`. The API only knows `state`. It does not
|
|
27
|
+
answer "unknown parameter": it answers **200 with every open issue**, and your
|
|
28
|
+
code (or your agent) believes it filtered. Nothing failed, so nothing alerts.
|
|
29
|
+
|
|
30
|
+
`silentparams` sends a handful of read-only requests with plausible misspellings
|
|
31
|
+
of each parameter in your URL and tells you, per name, whether the API rejects
|
|
32
|
+
it, accepts it as an alias, or ignores it without a word.
|
|
33
|
+
|
|
34
|
+
```console
|
|
35
|
+
$ silentparams "https://api.github.com/repos/python/cpython/issues?state=closed&per_page=3"
|
|
36
|
+
Parameter 'state=closed': removing it changes the response
|
|
37
|
+
states HTTP 200 IGNORED silently (2xx, same as without the parameter)
|
|
38
|
+
STATE HTTP 200 IGNORED silently (2xx, same as without the parameter)
|
|
39
|
+
zzb22fbb9f HTTP 200 IGNORED silently (2xx, same as without the parameter)
|
|
40
|
+
...
|
|
41
|
+
RESULT: this API silently ignores unknown parameter names. A typo in a filter returns unfiltered data with HTTP 200.
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
That is not a bug in GitHub. Ignoring unknown parameters is a common design
|
|
45
|
+
choice. It just means **a typo in your client is invisible**, so you should know
|
|
46
|
+
which APIs behave this way before you trust a filtered result.
|
|
47
|
+
|
|
48
|
+
## Install
|
|
49
|
+
|
|
50
|
+
```console
|
|
51
|
+
pip install silentparams # no dependencies, Python 3.10+
|
|
52
|
+
# or the latest code: pip install git+https://github.com/novasdiego1/silentparams
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
## How it works
|
|
56
|
+
|
|
57
|
+
For each query parameter `p` in the URL:
|
|
58
|
+
|
|
59
|
+
| Request | Purpose |
|
|
60
|
+
|---|---|
|
|
61
|
+
| the URL as given, twice | baseline, and a check that the response is stable |
|
|
62
|
+
| the URL without `p` | what the API returns when `p` is absent |
|
|
63
|
+
| `p` renamed (plural/singular, trailing `_`, case, `snake`/`kebab`/`camel`, doubled letter) and one nonsense name | the decoys |
|
|
64
|
+
|
|
65
|
+
Each decoy is classified by comparing normalized response bodies (JSON is
|
|
66
|
+
canonicalized, so key order does not matter):
|
|
67
|
+
|
|
68
|
+
| Verdict | Meaning |
|
|
69
|
+
|---|---|
|
|
70
|
+
| `IGNORED silently` | 2xx and the same response as without `p`: the name was dropped without a word |
|
|
71
|
+
| `rejected` | 4xx: the API refuses unknown names (the safe behaviour) |
|
|
72
|
+
| `alias` | 2xx and the same response as the baseline: the variant is accepted |
|
|
73
|
+
| `different` | 2xx with some other response: inconclusive |
|
|
74
|
+
|
|
75
|
+
It also tells you when removing `p` changes nothing, which usually means `p`
|
|
76
|
+
itself is being ignored.
|
|
77
|
+
|
|
78
|
+
## Options
|
|
79
|
+
|
|
80
|
+
```text
|
|
81
|
+
-H 'Name: value' extra header (repeatable), e.g. an Authorization token
|
|
82
|
+
--ignore-key KEY JSON key to ignore when comparing (timestamps, request ids)
|
|
83
|
+
--decoys N decoy names per parameter (default 6)
|
|
84
|
+
--max-requests N hard cap on requests sent (default 60)
|
|
85
|
+
--delay S seconds between requests (default 0.5)
|
|
86
|
+
--json machine-readable report
|
|
87
|
+
```
|
|
88
|
+
|
|
89
|
+
Exit code: `1` if the API silently ignores unknown names, `0` if not, `2` on
|
|
90
|
+
usage errors. That makes it usable as a CI check for an API you own.
|
|
91
|
+
|
|
92
|
+
If the same request returns different bodies (a timestamp, a request id), the
|
|
93
|
+
report warns that results are unreliable: add `--ignore-key` for those fields.
|
|
94
|
+
|
|
95
|
+
## Be polite
|
|
96
|
+
|
|
97
|
+
It only sends `GET` requests, waits between them, and stops at
|
|
98
|
+
`--max-requests`. Point it at APIs you are allowed to query, and mind their
|
|
99
|
+
rate limits.
|
|
100
|
+
|
|
101
|
+
## Why this exists
|
|
102
|
+
|
|
103
|
+
An auditor reported reaching a wrong conclusion this way. They tried four
|
|
104
|
+
spellings of a per-author filter (`author`, `agent`, `agent_id`, `pubkey`) on an
|
|
105
|
+
API that only declared `authors`. Every request returned 200 with unfiltered
|
|
106
|
+
rows, so they wrote that no such filter existed. The same API rejected a bad
|
|
107
|
+
`limit` value with 422: it checked values carefully and never checked names.
|
|
108
|
+
|
|
109
|
+
## License
|
|
110
|
+
|
|
111
|
+
MIT
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/silentparams/__init__.py
|
|
5
|
+
src/silentparams/__main__.py
|
|
6
|
+
src/silentparams/cli.py
|
|
7
|
+
src/silentparams/core.py
|
|
8
|
+
src/silentparams.egg-info/PKG-INFO
|
|
9
|
+
src/silentparams.egg-info/SOURCES.txt
|
|
10
|
+
src/silentparams.egg-info/dependency_links.txt
|
|
11
|
+
src/silentparams.egg-info/entry_points.txt
|
|
12
|
+
src/silentparams.egg-info/top_level.txt
|
|
13
|
+
tests/test_probe.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
silentparams
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""End-to-end tests against a local server with one lax and one strict endpoint.
|
|
2
|
+
|
|
3
|
+
The lax endpoint reproduces the case that motivated the tool: it knows `authors`
|
|
4
|
+
and `limit`, validates their values, and silently drops any other name.
|
|
5
|
+
"""
|
|
6
|
+
import json
|
|
7
|
+
import threading
|
|
8
|
+
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
|
|
9
|
+
from urllib.parse import parse_qsl, urlsplit
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
|
|
13
|
+
from silentparams.cli import main
|
|
14
|
+
from silentparams.core import Response, classify, decoy_names, http_fetch, normalize, probe
|
|
15
|
+
|
|
16
|
+
ROWS = [{"id": i, "author": ("alice" if i % 2 else "bob")} for i in range(10)]
|
|
17
|
+
KNOWN = {"authors", "limit"}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class Handler(BaseHTTPRequestHandler):
|
|
21
|
+
def log_message(self, *args):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
def do_GET(self):
|
|
25
|
+
parts = urlsplit(self.path)
|
|
26
|
+
q = dict(parse_qsl(parts.query))
|
|
27
|
+
if parts.path == "/strict" and set(q) - KNOWN:
|
|
28
|
+
return self._send(422, {"error": f"unknown parameter: {sorted(set(q) - KNOWN)[0]}"})
|
|
29
|
+
rows = ROWS
|
|
30
|
+
if "authors" in q:
|
|
31
|
+
rows = [r for r in rows if r["author"] in q["authors"].split(",")]
|
|
32
|
+
if "limit" in q:
|
|
33
|
+
if not q["limit"].isdigit() or not 0 < int(q["limit"]) <= 100:
|
|
34
|
+
return self._send(422, {"error": "bad limit"})
|
|
35
|
+
rows = rows[: int(q["limit"])]
|
|
36
|
+
self._send(200, {"rows": rows, "generated_at": self.server.tick()})
|
|
37
|
+
|
|
38
|
+
def _send(self, status, data):
|
|
39
|
+
body = json.dumps(data).encode()
|
|
40
|
+
self.send_response(status)
|
|
41
|
+
self.send_header("Content-Type", "application/json")
|
|
42
|
+
self.send_header("Content-Length", str(len(body)))
|
|
43
|
+
self.end_headers()
|
|
44
|
+
self.wfile.write(body)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
@pytest.fixture(scope="module")
|
|
48
|
+
def server():
|
|
49
|
+
srv = ThreadingHTTPServer(("127.0.0.1", 0), Handler)
|
|
50
|
+
clock = iter(range(10**9))
|
|
51
|
+
srv.tick = lambda: next(clock) # a volatile field, like a timestamp
|
|
52
|
+
threading.Thread(target=srv.serve_forever, daemon=True).start()
|
|
53
|
+
yield f"http://127.0.0.1:{srv.server_address[1]}"
|
|
54
|
+
srv.shutdown()
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def fixed_rng(n):
|
|
58
|
+
return "ab" * n
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def test_lax_api_is_caught(server):
|
|
62
|
+
report = probe(f"{server}/lax?authors=alice&limit=3", http_fetch(), delay=0,
|
|
63
|
+
ignore_keys=["generated_at"], rng=fixed_rng)
|
|
64
|
+
assert report.stable
|
|
65
|
+
assert report.ignores_unknown_names is True
|
|
66
|
+
authors = report.params[0]
|
|
67
|
+
assert authors.effective
|
|
68
|
+
assert "author" in authors.silently_ignored # the singular typo returns unfiltered rows with 200
|
|
69
|
+
assert "zzabababab" in authors.silently_ignored
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def test_strict_api_passes(server):
|
|
73
|
+
report = probe(f"{server}/strict?authors=alice", http_fetch(), delay=0,
|
|
74
|
+
ignore_keys=["generated_at"], rng=fixed_rng)
|
|
75
|
+
assert report.ignores_unknown_names is False
|
|
76
|
+
assert all(d.verdict == "rejected" for d in report.params[0].decoys)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_volatile_field_makes_results_unstable_without_ignore_key(server):
|
|
80
|
+
report = probe(f"{server}/lax?authors=alice", http_fetch(), delay=0, rng=fixed_rng)
|
|
81
|
+
assert not report.stable
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def test_request_budget_is_enforced(server):
|
|
85
|
+
with pytest.raises(RuntimeError, match="budget"):
|
|
86
|
+
probe(f"{server}/lax?authors=alice&limit=3", http_fetch(), delay=0, max_requests=4,
|
|
87
|
+
ignore_keys=["generated_at"])
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def test_cli_exit_codes(server, capsys):
|
|
91
|
+
assert main([f"{server}/lax?authors=alice", "--delay", "0", "--ignore-key", "generated_at"]) == 1
|
|
92
|
+
assert "silently ignores unknown parameter names" in capsys.readouterr().out
|
|
93
|
+
assert main([f"{server}/strict?authors=alice", "--delay", "0", "--ignore-key", "generated_at"]) == 0
|
|
94
|
+
assert main([f"{server}/lax", "--delay", "0"]) == 2 # nothing to probe
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_decoy_names():
|
|
98
|
+
names = decoy_names("authors", rng=fixed_rng)
|
|
99
|
+
assert names[0] == "author" and names[-1] == "zzabababab" and "authors" not in names
|
|
100
|
+
assert "pageSize" in decoy_names("page_size", limit=10, rng=fixed_rng)
|
|
101
|
+
assert "page_size" in decoy_names("pageSize", limit=10, rng=fixed_rng)
|
|
102
|
+
assert len(decoy_names("q", limit=3, rng=fixed_rng)) == 3
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def test_normalize_ignores_key_order_and_volatile_keys():
|
|
106
|
+
a, n = normalize(b'{"rows":[1,2],"t":1}', "application/json", ["t"])
|
|
107
|
+
b, _ = normalize(b'{"t":2, "rows":[1,2]}', "application/json", ["t"])
|
|
108
|
+
assert a == b and n == 2
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def test_classify():
|
|
112
|
+
base, without = Response(200, "aaa", 1), Response(200, "bbb", 1)
|
|
113
|
+
assert classify(Response(200, "bbb", 1), base, without) == "ignored"
|
|
114
|
+
assert classify(Response(422, "x", 1), base, without) == "rejected"
|
|
115
|
+
assert classify(Response(200, "aaa", 1), base, without) == "alias"
|
|
116
|
+
assert classify(Response(200, "ccc", 1), base, without) == "different"
|
|
117
|
+
assert classify(Response(503, "x", 1), base, without) == "error"
|