mirror of
https://github.com/3proxy/3proxy.git
synced 2026-08-26 09:55:48 +08:00
Add tests
This commit is contained in:
parent
7f430ccc79
commit
73fbf9d262
6
.github/workflows/c-cpp-Linux.yml
vendored
6
.github/workflows/c-cpp-Linux.yml
vendored
@ -2,9 +2,9 @@ name: C/C++ CI Linux
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
paths: [ '**.c', '**.h', 'Makefile.Linux', '.github/configs', '.github/workflows/c-cpp-Linux.yml' ]
|
paths: [ '**.c', '**.h', 'Makefile.Linux', 'tests/**', '.github/configs', '.github/workflows/c-cpp-Linux.yml' ]
|
||||||
pull_request:
|
pull_request:
|
||||||
paths: [ "**.c", "**.h", "Makefile.Linux", ".github/configs", ".github/workflows/c-cpp-Linux.yml" ]
|
paths: [ "**.c", "**.h", "Makefile.Linux", "tests/**", ".github/configs", ".github/workflows/c-cpp-Linux.yml" ]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@ -28,6 +28,8 @@ jobs:
|
|||||||
run: sudo apt-get update && sudo apt-get install -y libssl-dev libpam-dev libpcre2-dev
|
run: sudo apt-get update && sudo apt-get install -y libssl-dev libpam-dev libpcre2-dev
|
||||||
- name: make
|
- name: make
|
||||||
run: make -f Makefile.Linux
|
run: make -f Makefile.Linux
|
||||||
|
- name: regression tests
|
||||||
|
run: python3 tests/run.py
|
||||||
- name: mkdir
|
- name: mkdir
|
||||||
run: mkdir ~/3proxy
|
run: mkdir ~/3proxy
|
||||||
- name: make install
|
- name: make install
|
||||||
|
|||||||
6
.github/workflows/c-cpp-MacOS.yml
vendored
6
.github/workflows/c-cpp-MacOS.yml
vendored
@ -2,9 +2,9 @@ name: C/C++ CI MacOS
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
paths: [ '**.c', '**.h', 'Makefile.FreeBSD', '.github/configs', '.github/workflows/c-cpp-MacOS.yml' ]
|
paths: [ '**.c', '**.h', 'Makefile.FreeBSD', 'tests/**', '.github/configs', '.github/workflows/c-cpp-MacOS.yml' ]
|
||||||
pull_request:
|
pull_request:
|
||||||
paths: [ "**.c", "**.h", "Makefile.FreeBSD", ".github/configs", ".github/workflows/c-cpp-MacOS.yml" ]
|
paths: [ "**.c", "**.h", "Makefile.FreeBSD", "tests/**", ".github/configs", ".github/workflows/c-cpp-MacOS.yml" ]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@ -29,5 +29,7 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
LDFLAGS: "-L/usr/local/lib -L/opt/homebrew/lib -L/opt/homebrew/opt/openssl/lib"
|
LDFLAGS: "-L/usr/local/lib -L/opt/homebrew/lib -L/opt/homebrew/opt/openssl/lib"
|
||||||
CFLAGS: "-I/usr/local/include -I/opt/homebrew/include -I/usr/local/opt/openssl/include -I/opt/homebrew/opt/openssl/include"
|
CFLAGS: "-I/usr/local/include -I/opt/homebrew/include -I/usr/local/opt/openssl/include -I/opt/homebrew/opt/openssl/include"
|
||||||
|
- name: regression tests
|
||||||
|
run: python3 tests/run.py
|
||||||
- name: make clean MacOS
|
- name: make clean MacOS
|
||||||
run: make -f Makefile.FreeBSD clean
|
run: make -f Makefile.FreeBSD clean
|
||||||
|
|||||||
7
.github/workflows/c-cpp-Windows.yml
vendored
7
.github/workflows/c-cpp-Windows.yml
vendored
@ -2,9 +2,9 @@ name: C/C++ CI Windows
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
paths: [ '**.c', '**.h', 'Makefile.msvc', '.github/configs', '.github/workflows/c-cpp-Windows.yml' ]
|
paths: [ '**.c', '**.h', 'Makefile.msvc', 'tests/**', '.github/configs', '.github/workflows/c-cpp-Windows.yml' ]
|
||||||
pull_request:
|
pull_request:
|
||||||
paths: [ "**.c", "**.h", "Makefile.msvc", ".github/configs", ".github/workflows/c-cpp-Windows.yml" ]
|
paths: [ "**.c", "**.h", "Makefile.msvc", "tests/**", ".github/configs", ".github/workflows/c-cpp-Windows.yml" ]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@ -27,6 +27,8 @@ jobs:
|
|||||||
env:
|
env:
|
||||||
LDFLAGS: '-L "c:/msys64/mingw64/lib"'
|
LDFLAGS: '-L "c:/msys64/mingw64/lib"'
|
||||||
CFLAGS: '-I "c:/msys64/mingw64/include"'
|
CFLAGS: '-I "c:/msys64/mingw64/include"'
|
||||||
|
- name: regression tests (MinGW)
|
||||||
|
run: python tests\run.py --bin bin\3proxy.exe
|
||||||
- name: make clean Windows
|
- name: make clean Windows
|
||||||
run: make -f Makefile.win clean
|
run: make -f Makefile.win clean
|
||||||
- name: Add msbuild to PATH
|
- name: Add msbuild to PATH
|
||||||
@ -40,4 +42,5 @@ jobs:
|
|||||||
set "LIB=%LIB%;c:/vcpkg/installed/x64-windows-static/lib;c:/vcpkg/installed/x64-windows/lib"
|
set "LIB=%LIB%;c:/vcpkg/installed/x64-windows-static/lib;c:/vcpkg/installed/x64-windows/lib"
|
||||||
set "INCLUDE=%INCLUDE%;c:/vcpkg/installed/x64-windows-static/include;c:/vcpkg/installed/x64-windows/include"
|
set "INCLUDE=%INCLUDE%;c:/vcpkg/installed/x64-windows-static/include;c:/vcpkg/installed/x64-windows/include"
|
||||||
nmake /F Makefile.msvc WOLFSSL=1 || exit /b 1
|
nmake /F Makefile.msvc WOLFSSL=1 || exit /b 1
|
||||||
|
python tests\run.py --bin bin\3proxy.exe || exit /b 1
|
||||||
nmake /F Makefile.msvc clean
|
nmake /F Makefile.msvc clean
|
||||||
|
|||||||
13
.github/workflows/c-cpp-cmake.yml
vendored
13
.github/workflows/c-cpp-cmake.yml
vendored
@ -2,9 +2,9 @@ name: C/C++ CI cmake
|
|||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
paths: [ '**.c', '**.h', '**.cmake', 'CMakeLists.txt', '.github/configs', '.github/workflows/c-cpp-cmake.yml' ]
|
paths: [ '**.c', '**.h', '**.cmake', 'CMakeLists.txt', 'tests/**', '.github/configs', '.github/workflows/c-cpp-cmake.yml' ]
|
||||||
pull_request:
|
pull_request:
|
||||||
paths: [ "**.c", "**.h", "**.cmake", "CMakeLists.txt", ".github/configs", ".github/workflows/c-cpp-cmake.yml" ]
|
paths: [ "**.c", "**.h", "**.cmake", "CMakeLists.txt", "tests/**", ".github/configs", ".github/workflows/c-cpp-cmake.yml" ]
|
||||||
workflow_dispatch:
|
workflow_dispatch:
|
||||||
|
|
||||||
permissions:
|
permissions:
|
||||||
@ -43,7 +43,9 @@ jobs:
|
|||||||
cmake --build .
|
cmake --build .
|
||||||
mkdir ~/3proxy
|
mkdir ~/3proxy
|
||||||
DESTDIR=~/3proxy cmake --install .
|
DESTDIR=~/3proxy cmake --install .
|
||||||
cd .. && rm -rf build/
|
cd ..
|
||||||
|
python3 tests/run.py --bin build/bin/3proxy
|
||||||
|
rm -rf build/
|
||||||
- name: make with CMake Win
|
- name: make with CMake Win
|
||||||
if: ${{ startsWith(matrix.target, 'windows') }}
|
if: ${{ startsWith(matrix.target, 'windows') }}
|
||||||
shell: cmd
|
shell: cmd
|
||||||
@ -56,6 +58,7 @@ jobs:
|
|||||||
dir
|
dir
|
||||||
cmake --build .
|
cmake --build .
|
||||||
cd ..
|
cd ..
|
||||||
|
python tests\run.py || exit /b 1
|
||||||
rmdir /s /q build
|
rmdir /s /q build
|
||||||
|
|
||||||
wolfssl:
|
wolfssl:
|
||||||
@ -71,4 +74,6 @@ jobs:
|
|||||||
cd build
|
cd build
|
||||||
cmake ..
|
cmake ..
|
||||||
cmake --build .
|
cmake --build .
|
||||||
cd .. && rm -rf build/
|
cd ..
|
||||||
|
python3 tests/run.py --bin build/bin/3proxy
|
||||||
|
rm -rf build/
|
||||||
|
|||||||
2
tests/.gitignore
vendored
Normal file
2
tests/.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
__pycache__/
|
||||||
|
*.pyc
|
||||||
54
tests/README.md
Normal file
54
tests/README.md
Normal file
@ -0,0 +1,54 @@
|
|||||||
|
# Regression tests
|
||||||
|
|
||||||
|
python3 tests/run.py # every case
|
||||||
|
python3 tests/run.py httpsrv # cases whose name matches
|
||||||
|
python3 tests/run.py --bin build/bin/3proxy
|
||||||
|
python3 tests/run.py -v # print every check
|
||||||
|
python3 tests/run.py --keep # keep the configurations and logs
|
||||||
|
|
||||||
|
Python 3.6 or later and a built 3proxy are the only requirements: the suite
|
||||||
|
is standard library throughout, so it runs wherever 3proxy builds. With no
|
||||||
|
`--bin` it looks in `bin/`, then `build/bin/`, then the per-configuration
|
||||||
|
directories a multi-configuration CMake generator uses.
|
||||||
|
|
||||||
|
The proxy under test is also the origin server the tests talk to: the `http`
|
||||||
|
command's `echo` operation reports back how a request arrived - method, path,
|
||||||
|
query, host, and the source port it came from - and `data` generates a body
|
||||||
|
of a requested size, framing, status and pace. So a case can state what a
|
||||||
|
proxy should do to a request and then read off what actually reached the
|
||||||
|
other side.
|
||||||
|
|
||||||
|
## Adding a case
|
||||||
|
|
||||||
|
A case is a module under `cases/` exporting `run(t)`. It writes the
|
||||||
|
configurations it needs, starts them, and says what it expects:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def run(t):
|
||||||
|
srv = t.free_port()
|
||||||
|
t.start("my_case", f"""
|
||||||
|
log
|
||||||
|
auth iponly
|
||||||
|
allow *
|
||||||
|
http * /echo echo
|
||||||
|
httpsrv -p{srv}
|
||||||
|
""", ports=[srv])
|
||||||
|
|
||||||
|
r = t.http(f"http://127.0.0.1:{srv}/echo")
|
||||||
|
t.eq(200, r.status, "the server answers")
|
||||||
|
t.contains(r, "method=GET", "the method is reported")
|
||||||
|
```
|
||||||
|
|
||||||
|
Servers are stopped for you when the case ends, whether or not it passed.
|
||||||
|
|
||||||
|
`t` offers `http()` (direct, through an HTTP proxy, or over a CONNECT
|
||||||
|
tunnel), `socks_http()` and `socks_connect()` for SOCKS4 and SOCKS5,
|
||||||
|
`socks_udp_associate()`, `raw()` for bytes a real client would never send,
|
||||||
|
and `run_config()` for configurations that are meant to be rejected.
|
||||||
|
Assertions are `eq`, `ne`, `contains`, `not_contains`, `in_range`,
|
||||||
|
`not_in_range`, plus `ok`, `fail` and `skip`. `harness.field()` and
|
||||||
|
`int_field()` pull a single line out of an `echo` reply.
|
||||||
|
|
||||||
|
Note that access rules accumulate until `flush`, so a service section that
|
||||||
|
means to stand on its own should start with one - otherwise an earlier
|
||||||
|
`allow *` matches first and the rule under test is never reached.
|
||||||
69
tests/cases/admin.py
Normal file
69
tests/cases/admin.py
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
"""The admin interface, now a set of handlers on the HTTP server."""
|
||||||
|
|
||||||
|
|
||||||
|
def run(t):
|
||||||
|
adm = t.free_port()
|
||||||
|
lim = t.free_port()
|
||||||
|
t.start("admin", f"""
|
||||||
|
log
|
||||||
|
auth iponly
|
||||||
|
allow *
|
||||||
|
countin 1 D 100 * * *
|
||||||
|
countin 2 D 200 * * *
|
||||||
|
admin -p{adm}
|
||||||
|
|
||||||
|
flush
|
||||||
|
auth iponly
|
||||||
|
allow *
|
||||||
|
admin -p{lim} -s1
|
||||||
|
""", ports=[adm, lim])
|
||||||
|
|
||||||
|
url = f"http://127.0.0.1:{adm}"
|
||||||
|
|
||||||
|
# --- the predefined pages -----------------------------------------
|
||||||
|
t.eq(200, t.http(url + "/").status, "the main page")
|
||||||
|
t.eq(200, t.http(url + "/C").status, "the counters page")
|
||||||
|
t.eq(200, t.http(url + "/R").status, "the reload page")
|
||||||
|
t.eq(200, t.http(url + "/S").status, "the services page")
|
||||||
|
|
||||||
|
counters = t.http(url + "/C")
|
||||||
|
t.contains(counters, "countin", "the counters page names the counter type")
|
||||||
|
t.contains(counters, "<tr>", "the counters page renders a table")
|
||||||
|
t.contains(t.http(url + "/R"), "Reload", "the reload page confirms the request")
|
||||||
|
t.contains(t.http(url + "/S"), "<", "the services page returns markup")
|
||||||
|
|
||||||
|
# --- the menu no longer offers the removed config editor -----------
|
||||||
|
main = t.http(url + "/")
|
||||||
|
t.contains(main, "HREF='/C'", "the menu links to the counters")
|
||||||
|
t.contains(main, "HREF='/R'", "the menu links to reload")
|
||||||
|
t.contains(main, "HREF='/S'", "the menu links to the services")
|
||||||
|
t.not_contains(main, "HREF='/F'",
|
||||||
|
"the menu no longer links to the config editor")
|
||||||
|
|
||||||
|
# /F and /U are gone, so they fall through to the catch-all rule
|
||||||
|
t.eq(200, t.http(url + "/F").status, "the removed /F falls through")
|
||||||
|
t.eq(200, t.http(url + "/U").status, "the removed /U falls through")
|
||||||
|
t.contains(t.http(url + "/F"), "configuration", "/F yields the main page")
|
||||||
|
|
||||||
|
# --- counter control through the glob ------------------------------
|
||||||
|
# /C<action><number> is routed by the /C* rule, the action arriving as
|
||||||
|
# the glob
|
||||||
|
t.http(url + "/CD0")
|
||||||
|
t.contains(t.http(url + "/C"), ">NO<", "a counter can be disabled")
|
||||||
|
t.http(url + "/CS0")
|
||||||
|
t.contains(t.http(url + "/C"), ">YES<", "a counter can be enabled again")
|
||||||
|
|
||||||
|
# --- limited mode ---------------------------------------------------
|
||||||
|
limited = f"http://127.0.0.1:{lim}"
|
||||||
|
t.eq(200, t.http(limited + "/").status, "limited mode serves the main page")
|
||||||
|
t.eq(200, t.http(limited + "/C").status, "limited mode serves the counters")
|
||||||
|
t.not_contains(t.http(limited + "/R"), "Reload scheduled",
|
||||||
|
"limited mode refuses a reload")
|
||||||
|
|
||||||
|
# --- the writable command is gone ------------------------------------
|
||||||
|
output = t.run_config("writable", f"""
|
||||||
|
log
|
||||||
|
writable
|
||||||
|
admin -p{t.free_port()}
|
||||||
|
""")
|
||||||
|
t.contains(output, "Unknown command", "the writable command is rejected")
|
||||||
49
tests/cases/httpsrv_auth.py
Normal file
49
tests/cases/httpsrv_auth.py
Normal file
@ -0,0 +1,49 @@
|
|||||||
|
"""Authentication and access rules in front of the HTTP server."""
|
||||||
|
|
||||||
|
|
||||||
|
def run(t):
|
||||||
|
srv = t.free_port()
|
||||||
|
openport = t.free_port()
|
||||||
|
t.start("httpsrv_auth", f"""
|
||||||
|
log
|
||||||
|
http * /echo echo
|
||||||
|
auth strong
|
||||||
|
users alice:CL:secret bob:CL:hunter2
|
||||||
|
allow alice
|
||||||
|
httpsrv -p{srv}
|
||||||
|
|
||||||
|
flush
|
||||||
|
http * /echo echo
|
||||||
|
auth iponly
|
||||||
|
allow *
|
||||||
|
httpsrv -p{openport}
|
||||||
|
""", ports=[srv, openport])
|
||||||
|
|
||||||
|
url = f"http://127.0.0.1:{srv}"
|
||||||
|
|
||||||
|
r = t.http(url + "/echo")
|
||||||
|
t.eq(401, r.status, "no credentials gives 401")
|
||||||
|
t.ne(None, r.header("WWW-Authenticate"),
|
||||||
|
"the 401 carries a WWW-Authenticate header")
|
||||||
|
|
||||||
|
t.eq(200, t.http(url + "/echo", auth=("alice", "secret")).status,
|
||||||
|
"valid credentials pass")
|
||||||
|
t.eq(401, t.http(url + "/echo", auth=("alice", "wrong")).status,
|
||||||
|
"a wrong password gives 401")
|
||||||
|
t.eq(401, t.http(url + "/echo", auth=("nobody", "secret")).status,
|
||||||
|
"an unknown user gives 401")
|
||||||
|
|
||||||
|
# bob authenticates, but no rule admits him
|
||||||
|
t.eq(403, t.http(url + "/echo", auth=("bob", "hunter2")).status,
|
||||||
|
"authenticated but not allowed gives 403")
|
||||||
|
|
||||||
|
# authentication comes before dispatch, so an unmatched URL still needs it
|
||||||
|
t.eq(401, t.http(url + "/nosuchpath").status,
|
||||||
|
"authentication precedes the rule lookup")
|
||||||
|
|
||||||
|
# the second service kept its own iponly authentication
|
||||||
|
t.eq(200, t.http(f"http://127.0.0.1:{openport}/echo").status,
|
||||||
|
"the open service needs no credentials")
|
||||||
|
|
||||||
|
t.contains(t.http(url + "/echo", auth=("alice", "secret")), "path=/echo",
|
||||||
|
"an authenticated request is dispatched")
|
||||||
78
tests/cases/httpsrv_ops.py
Normal file
78
tests/cases/httpsrv_ops.py
Normal file
@ -0,0 +1,78 @@
|
|||||||
|
"""The built-in HTTP server: the echo and data operations."""
|
||||||
|
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
def run(t):
|
||||||
|
srv = t.free_port()
|
||||||
|
t.start("httpsrv_ops", f"""
|
||||||
|
log
|
||||||
|
auth iponly
|
||||||
|
allow *
|
||||||
|
http * /echo* echo
|
||||||
|
http * /data data
|
||||||
|
http * /small data size=64
|
||||||
|
httpsrv -p{srv}
|
||||||
|
""", ports=[srv])
|
||||||
|
|
||||||
|
url = f"http://127.0.0.1:{srv}"
|
||||||
|
|
||||||
|
# --- echo: request introspection ---------------------------------
|
||||||
|
r = t.http(url + "/echo?a=1")
|
||||||
|
t.eq(200, r.status, "echo answers 200")
|
||||||
|
t.contains(r, "method=GET", "echo reports the method")
|
||||||
|
t.contains(r, "path=/echo", "echo reports the path")
|
||||||
|
t.contains(r, "query=a=1", "echo reports the query")
|
||||||
|
t.contains(r, "peer.addr=127.0.0.1", "echo reports the peer address")
|
||||||
|
t.contains(r, f"host=127.0.0.1:{srv}", "echo reports the Host header")
|
||||||
|
|
||||||
|
# the glob is the wildcard-matched tail, which is how admin routes its
|
||||||
|
# sub-pages
|
||||||
|
r = t.http(url + "/echoXYZ")
|
||||||
|
t.contains(r, "glob=XYZ", "echo reports the glob text")
|
||||||
|
t.contains(r, "glob.len=3", "echo reports the glob length")
|
||||||
|
|
||||||
|
# --- data: generated payload -------------------------------------
|
||||||
|
t.eq(1000, t.http(url + "/data?size=1000").length, "data honours size")
|
||||||
|
t.eq(0, t.http(url + "/data?size=0").length, "data size=0 sends an empty body")
|
||||||
|
t.eq(64, t.http(url + "/small").length, "data takes its size from the rule")
|
||||||
|
t.eq(1000, t.http(url + "/small?size=1000").length,
|
||||||
|
"the query overrides the rule parameters")
|
||||||
|
|
||||||
|
# a size past one block exercises the send loop
|
||||||
|
t.eq(70000, t.http(url + "/data?size=70000").length,
|
||||||
|
"data spans several blocks")
|
||||||
|
t.eq(70000, t.http(url + "/data?size=70000&block=1024").length,
|
||||||
|
"data honours the block size")
|
||||||
|
|
||||||
|
# --- status and framing ------------------------------------------
|
||||||
|
t.eq(404, t.http(url + "/data?size=10&status=404").status,
|
||||||
|
"data honours the status")
|
||||||
|
t.eq(503, t.http(url + "/data?size=10&status=503").status,
|
||||||
|
"data returns 503 when asked")
|
||||||
|
t.eq(200, t.http(url + "/data?size=10&status=99").status,
|
||||||
|
"an out-of-range status falls back to 200")
|
||||||
|
|
||||||
|
r = t.http(url + "/data?size=100")
|
||||||
|
t.eq("100", r.header("Content-Length"), "an identity reply sets Content-Length")
|
||||||
|
|
||||||
|
r = t.http(url + "/data?size=100&chunked=1")
|
||||||
|
t.eq("chunked", r.header("Transfer-Encoding"),
|
||||||
|
"a chunked reply sets Transfer-Encoding")
|
||||||
|
t.eq(None, r.header("Content-Length"),
|
||||||
|
"a chunked reply omits Content-Length")
|
||||||
|
t.eq(100, r.length, "a chunked body decodes to the size asked for")
|
||||||
|
t.eq(70000, t.http(url + "/data?size=70000&chunked=1").length,
|
||||||
|
"a chunked body spans several blocks")
|
||||||
|
|
||||||
|
# --- delay --------------------------------------------------------
|
||||||
|
start = time.time()
|
||||||
|
t.http(url + "/data?size=4096&block=1024&delay=100")
|
||||||
|
elapsed = time.time() - start
|
||||||
|
if elapsed >= 0.3:
|
||||||
|
t.ok("delay slows the transfer")
|
||||||
|
else:
|
||||||
|
t.fail("delay slows the transfer", ">=0.3s", f"{elapsed:.2f}s")
|
||||||
|
|
||||||
|
# --- unmatched ----------------------------------------------------
|
||||||
|
t.eq(404, t.http(url + "/nosuchthing").status, "an unmatched URL gives 404")
|
||||||
64
tests/cases/httpsrv_parsing.py
Normal file
64
tests/cases/httpsrv_parsing.py
Normal file
@ -0,0 +1,64 @@
|
|||||||
|
"""Request parsing: decoding, path safety, malformed and oversized input.
|
||||||
|
|
||||||
|
These go over a raw socket, because a well-behaved client would normalise
|
||||||
|
most of them away before they ever reached the server.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def run(t):
|
||||||
|
srv = t.free_port()
|
||||||
|
t.start("httpsrv_parsing", f"""
|
||||||
|
log
|
||||||
|
auth iponly
|
||||||
|
allow *
|
||||||
|
http * /echo* echo
|
||||||
|
http * /safe/* echo
|
||||||
|
httpsrv -p{srv}
|
||||||
|
""", ports=[srv])
|
||||||
|
|
||||||
|
def request(path, host="t", extra=""):
|
||||||
|
return t.raw(srv, f"GET {path} HTTP/1.0\r\nHost: {host}\r\n{extra}\r\n")
|
||||||
|
|
||||||
|
# --- percent-decoding ---------------------------------------------
|
||||||
|
reply = request("/%65cho")
|
||||||
|
t.contains(reply, "200 OK", "a percent-encoded path is decoded before matching")
|
||||||
|
t.contains(reply, "path=/echo", "the decoded path is what gets reported")
|
||||||
|
t.contains(request("/echo%20space"), "glob= space",
|
||||||
|
"an encoded space decodes into the glob")
|
||||||
|
|
||||||
|
# --- traversal -----------------------------------------------------
|
||||||
|
for path in ("/safe/../etc/passwd", "/safe/%2e%2e/etc", "/safe/..%2fetc",
|
||||||
|
"/echo/../../x"):
|
||||||
|
t.not_contains(request(path), "200 OK", f"traversal is refused: {path}")
|
||||||
|
|
||||||
|
t.contains(request("/safe/./ok"), "200 OK",
|
||||||
|
"a harmless dot segment is still served")
|
||||||
|
|
||||||
|
# --- injection ------------------------------------------------------
|
||||||
|
t.not_contains(request("/echo%0d%0aInjected:%20yes"), "Injected: yes",
|
||||||
|
"an encoded CRLF cannot inject a header")
|
||||||
|
t.not_contains(request("/echo%00cut"), "200 OK", "an encoded NUL is refused")
|
||||||
|
|
||||||
|
# a header value cannot smuggle a newline into the echoed output
|
||||||
|
reply = request("/echo", host="evil", extra="X-Injected: yes\r\n")
|
||||||
|
t.not_contains(reply, "host=evil\nX-Injected",
|
||||||
|
"header values stay in their own fields")
|
||||||
|
|
||||||
|
# --- malformed ------------------------------------------------------
|
||||||
|
t.not_contains(t.raw(srv, "GARBAGE\r\n\r\n"), "200 OK",
|
||||||
|
"a malformed request line is not served")
|
||||||
|
t.not_contains(t.raw(srv, "GET\r\n\r\n"), "200 OK",
|
||||||
|
"a request line with no URL is not served")
|
||||||
|
|
||||||
|
# an over-long path has to be refused rather than quietly truncated to
|
||||||
|
# something shorter that might match another rule
|
||||||
|
t.not_contains(request("/echo" + "a" * 9000), "200 OK",
|
||||||
|
"an over-long path is refused, not truncated")
|
||||||
|
|
||||||
|
# --- methods --------------------------------------------------------
|
||||||
|
url = f"http://127.0.0.1:{srv}"
|
||||||
|
t.eq(200, t.http(url + "/echo", method="HEAD").status, "HEAD is accepted")
|
||||||
|
r = t.http(url + "/echo", method="POST", body="payload=1",
|
||||||
|
headers={"Content-Type": "application/x-www-form-urlencoded"})
|
||||||
|
t.contains(r, "method=POST", "POST reaches the handler")
|
||||||
|
t.contains(r, "content.length=9", "the POST content length is parsed")
|
||||||
69
tests/cases/httpsrv_rules.py
Normal file
69
tests/cases/httpsrv_rules.py
Normal file
@ -0,0 +1,69 @@
|
|||||||
|
"""Rule dispatch: host and URL patterns, and per-service rule sets."""
|
||||||
|
|
||||||
|
|
||||||
|
def run(t):
|
||||||
|
srv = t.free_port()
|
||||||
|
srv2 = t.free_port()
|
||||||
|
t.start("httpsrv_rules", f"""
|
||||||
|
log
|
||||||
|
auth iponly
|
||||||
|
allow *
|
||||||
|
http * /exact echo
|
||||||
|
http * /pre* echo
|
||||||
|
http * *.suffix echo
|
||||||
|
http * *mid* echo
|
||||||
|
http host.example.com /byhost echo
|
||||||
|
http *.wild.example.com /bywild echo
|
||||||
|
http * /only-first echo
|
||||||
|
httpsrv -p{srv}
|
||||||
|
|
||||||
|
flush
|
||||||
|
auth iponly
|
||||||
|
allow *
|
||||||
|
http * /only-second echo
|
||||||
|
httpsrv -p{srv2}
|
||||||
|
""", ports=[srv, srv2])
|
||||||
|
|
||||||
|
url = f"http://127.0.0.1:{srv}"
|
||||||
|
|
||||||
|
# --- URL patterns -------------------------------------------------
|
||||||
|
t.eq(200, t.http(url + "/exact").status, "an exact URL matches")
|
||||||
|
t.eq(404, t.http(url + "/exactly").status,
|
||||||
|
"an exact URL does not match a longer path")
|
||||||
|
t.eq(200, t.http(url + "/pre").status, "a prefix matches the bare prefix")
|
||||||
|
t.eq(200, t.http(url + "/pretty/deep").status,
|
||||||
|
"a prefix matches a longer path")
|
||||||
|
t.eq(200, t.http(url + "/any.suffix").status, "a suffix matches")
|
||||||
|
t.eq(404, t.http(url + "/any.suffixx").status,
|
||||||
|
"a suffix is anchored at the end")
|
||||||
|
t.eq(200, t.http(url + "/xxmidxx").status, "a substring matches")
|
||||||
|
t.eq(404, t.http(url + "/nomatch").status, "an unmatched URL gives 404")
|
||||||
|
|
||||||
|
# --- host patterns ------------------------------------------------
|
||||||
|
def with_host(path, host):
|
||||||
|
return t.http(url + path, headers={"Host": host})
|
||||||
|
|
||||||
|
t.eq(200, with_host("/byhost", "host.example.com").status,
|
||||||
|
"an exact host matches")
|
||||||
|
t.eq(404, with_host("/byhost", "other.example.com").status,
|
||||||
|
"another host does not match")
|
||||||
|
t.eq(200, with_host("/bywild", "a.wild.example.com").status,
|
||||||
|
"a wildcard host matches")
|
||||||
|
t.eq(404, with_host("/bywild", "a.other.example.com").status,
|
||||||
|
"a wildcard host rejects another domain")
|
||||||
|
|
||||||
|
# the rules are ordered, and the first match wins
|
||||||
|
t.contains(t.http(url + "/exact"), "path=/exact",
|
||||||
|
"the first matching rule handles the request")
|
||||||
|
|
||||||
|
# --- per-service rule sets ----------------------------------------
|
||||||
|
# Rules accumulate until a service starts, which takes them; later rules
|
||||||
|
# belong to the next service only.
|
||||||
|
t.eq(200, t.http(f"http://127.0.0.1:{srv}/only-first").status,
|
||||||
|
"the first service has its own rules")
|
||||||
|
t.eq(404, t.http(f"http://127.0.0.1:{srv}/only-second").status,
|
||||||
|
"the first service does not have the later rules")
|
||||||
|
t.eq(200, t.http(f"http://127.0.0.1:{srv2}/only-second").status,
|
||||||
|
"the second service has its own rules")
|
||||||
|
t.eq(404, t.http(f"http://127.0.0.1:{srv2}/only-first").status,
|
||||||
|
"the second service does not have the earlier rules")
|
||||||
131
tests/cases/parent_ports.py
Normal file
131
tests/cases/parent_ports.py
Normal file
@ -0,0 +1,131 @@
|
|||||||
|
"""extport and intport: binding the local side of a connection to a range.
|
||||||
|
|
||||||
|
Access rules accumulate until "flush": without it an earlier "allow *"
|
||||||
|
matches first and the rule carrying the range is never reached.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from harness import int_field
|
||||||
|
|
||||||
|
LOW = 21400
|
||||||
|
HIGH = 21449
|
||||||
|
ILOW = 21500
|
||||||
|
IHIGH = 21549
|
||||||
|
|
||||||
|
|
||||||
|
def run(t):
|
||||||
|
srv = t.free_port()
|
||||||
|
prx = t.free_port()
|
||||||
|
sks = t.free_port()
|
||||||
|
meth = t.free_port()
|
||||||
|
|
||||||
|
t.start("parent_ports", f"""
|
||||||
|
log
|
||||||
|
auth iponly
|
||||||
|
allow *
|
||||||
|
http * /echo* echo
|
||||||
|
httpsrv -p{srv}
|
||||||
|
|
||||||
|
# every outgoing connection binds inside the range
|
||||||
|
flush
|
||||||
|
auth iponly
|
||||||
|
allow *
|
||||||
|
parent 1000 extport 0.0.0.0 {LOW}-{HIGH}
|
||||||
|
proxy -p{prx}
|
||||||
|
|
||||||
|
# the range applies only to CONNECT: an HTTP proxy CONNECT is
|
||||||
|
# HTTP_CONNECT, the bare CONNECT operation being the SOCKS one
|
||||||
|
flush
|
||||||
|
auth iponly
|
||||||
|
allow * * * * HTTP_CONNECT
|
||||||
|
parent 1000 extport 0.0.0.0 {LOW}-{HIGH}
|
||||||
|
allow *
|
||||||
|
proxy -p{meth}
|
||||||
|
|
||||||
|
# socks, for the same setting on another service
|
||||||
|
flush
|
||||||
|
auth iponly
|
||||||
|
allow *
|
||||||
|
parent 1000 extport 0.0.0.0 {LOW}-{HIGH}
|
||||||
|
socks -p{sks}
|
||||||
|
""", ports=[srv, prx, sks, meth])
|
||||||
|
|
||||||
|
origin = f"http://127.0.0.1:{srv}"
|
||||||
|
proxy = f"127.0.0.1:{prx}"
|
||||||
|
|
||||||
|
# --- extport ---------------------------------------------------------
|
||||||
|
# the origin reports the source port it actually saw
|
||||||
|
port = int_field(t.http(origin + "/echo", proxy=proxy), "peer.port")
|
||||||
|
t.in_range(port, LOW, HIGH, "the outgoing connection binds inside the range")
|
||||||
|
|
||||||
|
seen = []
|
||||||
|
for _ in range(5):
|
||||||
|
seen.append(int_field(t.http(origin + "/echo", proxy=proxy), "peer.port"))
|
||||||
|
outside = [p for p in seen if p is None or not LOW <= p <= HIGH]
|
||||||
|
t.eq([], outside, "repeated connections all bind inside the range")
|
||||||
|
|
||||||
|
port = int_field(t.socks_http(f"127.0.0.1:{sks}", origin + "/echo"),
|
||||||
|
"peer.port")
|
||||||
|
t.in_range(port, LOW, HIGH,
|
||||||
|
"socks binds the outgoing connection inside the range")
|
||||||
|
|
||||||
|
# --- per-method scoping ------------------------------------------------
|
||||||
|
method_proxy = f"127.0.0.1:{meth}"
|
||||||
|
port = int_field(t.http(origin + "/echo", proxy=method_proxy, tunnel=True),
|
||||||
|
"peer.port")
|
||||||
|
t.in_range(port, LOW, HIGH, "CONNECT uses the range its rule sets")
|
||||||
|
|
||||||
|
# a plain GET matches the later rule, which sets no range
|
||||||
|
port = int_field(t.http(origin + "/echo", proxy=method_proxy), "peer.port")
|
||||||
|
t.not_in_range(port, LOW, HIGH,
|
||||||
|
"a method outside that rule keeps an ephemeral port")
|
||||||
|
|
||||||
|
# --- intport -----------------------------------------------------------
|
||||||
|
# A UDP association allocates its socket after the destination is known,
|
||||||
|
# so the range has to be applied when the rule matches rather than when
|
||||||
|
# the chain is walked.
|
||||||
|
udps = t.free_port()
|
||||||
|
t.start("parent_intport", f"""
|
||||||
|
log
|
||||||
|
flush
|
||||||
|
auth iponly
|
||||||
|
allow *
|
||||||
|
parent 1000 intport 0.0.0.0 {ILOW}-{IHIGH}
|
||||||
|
socks -p{udps}
|
||||||
|
""", ports=[udps])
|
||||||
|
t.in_range(t.socks_udp_associate(udps), ILOW, IHIGH,
|
||||||
|
"UDP ASSOCIATE binds inside the internal range")
|
||||||
|
|
||||||
|
# without a range the association still works, on an ephemeral port
|
||||||
|
udps2 = t.free_port()
|
||||||
|
t.start("parent_intport_none", f"""
|
||||||
|
log
|
||||||
|
flush
|
||||||
|
auth iponly
|
||||||
|
allow *
|
||||||
|
socks -p{udps2}
|
||||||
|
""", ports=[udps2])
|
||||||
|
t.ne(None, t.socks_udp_associate(udps2),
|
||||||
|
"UDP ASSOCIATE works without a range")
|
||||||
|
|
||||||
|
# --- configuration errors ------------------------------------------------
|
||||||
|
dead = t.free_port()
|
||||||
|
t.contains(t.run_config("badaddr", f"""
|
||||||
|
log
|
||||||
|
allow *
|
||||||
|
parent 1000 extport 127.0.0.1 {LOW}-{HIGH}
|
||||||
|
proxy -p{dead}
|
||||||
|
"""), "requires 0.0.0.0", "a non-zero address with extport is rejected")
|
||||||
|
|
||||||
|
t.contains(t.run_config("badrange", f"""
|
||||||
|
log
|
||||||
|
allow *
|
||||||
|
parent 1000 extport 0.0.0.0 notaport
|
||||||
|
proxy -p{dead}
|
||||||
|
"""), "bad port range", "a malformed range is rejected")
|
||||||
|
|
||||||
|
t.contains(t.run_config("badorder", f"""
|
||||||
|
log
|
||||||
|
allow *
|
||||||
|
parent 1000 extport 0.0.0.0 {HIGH}-{LOW}
|
||||||
|
proxy -p{dead}
|
||||||
|
"""), "bad port range", "a reversed range is rejected")
|
||||||
108
tests/cases/proxy_http.py
Normal file
108
tests/cases/proxy_http.py
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
"""The HTTP proxy, with the built-in server as the origin.
|
||||||
|
|
||||||
|
Access rules accumulate until "flush", so each service section here starts
|
||||||
|
from a clean list.
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
def run(t):
|
||||||
|
srv = t.free_port()
|
||||||
|
other = t.free_port()
|
||||||
|
prx = t.free_port()
|
||||||
|
deny = t.free_port()
|
||||||
|
auth = t.free_port()
|
||||||
|
|
||||||
|
t.start("proxy_http", f"""
|
||||||
|
log
|
||||||
|
auth iponly
|
||||||
|
allow *
|
||||||
|
http * /echo* echo
|
||||||
|
http * /data data
|
||||||
|
httpsrv -p{srv}
|
||||||
|
|
||||||
|
# a second origin, used as a destination the rules must keep out
|
||||||
|
flush
|
||||||
|
auth iponly
|
||||||
|
allow *
|
||||||
|
http * /echo* echo
|
||||||
|
httpsrv -p{other}
|
||||||
|
|
||||||
|
# an open proxy
|
||||||
|
flush
|
||||||
|
auth iponly
|
||||||
|
allow *
|
||||||
|
proxy -p{prx}
|
||||||
|
|
||||||
|
# only the first origin is reachable
|
||||||
|
flush
|
||||||
|
auth iponly
|
||||||
|
allow * * * {srv}
|
||||||
|
proxy -p{deny}
|
||||||
|
|
||||||
|
# credentials required
|
||||||
|
flush
|
||||||
|
auth strong
|
||||||
|
users alice:CL:secret
|
||||||
|
allow alice
|
||||||
|
proxy -p{auth}
|
||||||
|
""", ports=[srv, other, prx, deny, auth])
|
||||||
|
|
||||||
|
origin = f"http://127.0.0.1:{srv}"
|
||||||
|
second = f"http://127.0.0.1:{other}"
|
||||||
|
open_proxy = f"127.0.0.1:{prx}"
|
||||||
|
|
||||||
|
# --- plain proxying -------------------------------------------------
|
||||||
|
r = t.http(origin + "/echo", proxy=open_proxy)
|
||||||
|
t.eq(200, r.status, "a GET through the proxy")
|
||||||
|
t.contains(r, "path=/echo", "the origin sees the proxied path")
|
||||||
|
t.contains(r, "peer.addr=127.0.0.1", "the origin sees the proxy as the peer")
|
||||||
|
|
||||||
|
t.eq(10000, t.http(origin + "/data?size=10000", proxy=open_proxy).length,
|
||||||
|
"a sized body survives proxying")
|
||||||
|
t.eq(10000,
|
||||||
|
t.http(origin + "/data?size=10000&chunked=1", proxy=open_proxy).length,
|
||||||
|
"a chunked body survives proxying")
|
||||||
|
t.eq(503, t.http(origin + "/data?size=5&status=503", proxy=open_proxy).status,
|
||||||
|
"the origin status is relayed")
|
||||||
|
|
||||||
|
# --- POST and keep-alive ---------------------------------------------
|
||||||
|
r = t.http(origin + "/echo", proxy=open_proxy, method="POST", body="x=1")
|
||||||
|
t.contains(r, "method=POST", "POST is proxied")
|
||||||
|
|
||||||
|
# two requests on one connection, which may carry different methods
|
||||||
|
conn = t.connection("127.0.0.1", srv, proxy=open_proxy)
|
||||||
|
try:
|
||||||
|
first = t.http(origin + "/echo", proxy=open_proxy, method="POST",
|
||||||
|
body="x=1", conn=conn)
|
||||||
|
second_reply = t.http(origin + "/echo", proxy=open_proxy, conn=conn)
|
||||||
|
t.eq((200, 200), (first.status, second_reply.status),
|
||||||
|
"two requests on one proxied connection")
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
# --- CONNECT ----------------------------------------------------------
|
||||||
|
t.eq(200, t.http(origin + "/echo", proxy=open_proxy, tunnel=True).status,
|
||||||
|
"CONNECT tunnels to the origin")
|
||||||
|
|
||||||
|
# --- access control ----------------------------------------------------
|
||||||
|
denying = f"127.0.0.1:{deny}"
|
||||||
|
t.eq(200, t.http(origin + "/echo", proxy=denying).status,
|
||||||
|
"the permitted destination is reachable")
|
||||||
|
t.ne(200, t.http(second + "/echo", proxy=denying).status,
|
||||||
|
"a destination outside the rules is refused")
|
||||||
|
t.ne(200, t.http(second + "/echo", proxy=denying, tunnel=True).status,
|
||||||
|
"CONNECT to a destination outside the rules is refused")
|
||||||
|
# the open proxy still reaches it, so the refusal came from the rules
|
||||||
|
t.eq(200, t.http(second + "/echo", proxy=open_proxy).status,
|
||||||
|
"the same destination is reachable through the open proxy")
|
||||||
|
|
||||||
|
# --- proxy authentication -----------------------------------------------
|
||||||
|
needs_auth = f"127.0.0.1:{auth}"
|
||||||
|
t.eq(407, t.http(origin + "/echo", proxy=needs_auth).status,
|
||||||
|
"the proxy demands credentials")
|
||||||
|
t.eq(200, t.http(origin + "/echo", proxy=needs_auth,
|
||||||
|
proxy_auth=("alice", "secret")).status,
|
||||||
|
"valid proxy credentials pass")
|
||||||
|
t.eq(407, t.http(origin + "/echo", proxy=needs_auth,
|
||||||
|
proxy_auth=("alice", "wrong")).status,
|
||||||
|
"wrong proxy credentials are refused")
|
||||||
57
tests/cases/socks.py
Normal file
57
tests/cases/socks.py
Normal file
@ -0,0 +1,57 @@
|
|||||||
|
"""The SOCKS proxy, reaching the built-in server."""
|
||||||
|
|
||||||
|
|
||||||
|
def run(t):
|
||||||
|
srv = t.free_port()
|
||||||
|
sks = t.free_port()
|
||||||
|
sauth = t.free_port()
|
||||||
|
|
||||||
|
t.start("socks", f"""
|
||||||
|
log
|
||||||
|
auth iponly
|
||||||
|
allow *
|
||||||
|
http * /echo* echo
|
||||||
|
http * /data data
|
||||||
|
httpsrv -p{srv}
|
||||||
|
|
||||||
|
flush
|
||||||
|
auth iponly
|
||||||
|
allow *
|
||||||
|
socks -p{sks}
|
||||||
|
|
||||||
|
flush
|
||||||
|
auth strong
|
||||||
|
users alice:CL:secret
|
||||||
|
allow alice
|
||||||
|
socks -p{sauth}
|
||||||
|
""", ports=[srv, sks, sauth])
|
||||||
|
|
||||||
|
origin = f"http://127.0.0.1:{srv}"
|
||||||
|
plain = f"127.0.0.1:{sks}"
|
||||||
|
guarded = f"127.0.0.1:{sauth}"
|
||||||
|
|
||||||
|
# --- SOCKS5 ---------------------------------------------------------
|
||||||
|
r = t.socks_http(plain, origin + "/echo")
|
||||||
|
t.eq(200, r.status, "a SOCKS5 connection")
|
||||||
|
t.contains(r, "path=/echo", "the origin sees the request made over SOCKS5")
|
||||||
|
t.eq(10000, t.socks_http(plain, origin + "/data?size=10000").length,
|
||||||
|
"a body survives SOCKS5")
|
||||||
|
|
||||||
|
# resolution delegated to the proxy
|
||||||
|
t.eq(200, t.socks_http(plain, f"http://localhost:{srv}/echo",
|
||||||
|
remote_dns=True).status,
|
||||||
|
"SOCKS5 resolves the hostname itself")
|
||||||
|
|
||||||
|
# --- SOCKS4 -----------------------------------------------------------
|
||||||
|
t.eq(200, t.socks_http(plain, origin + "/echo", socks4=True).status,
|
||||||
|
"a SOCKS4 connection")
|
||||||
|
|
||||||
|
# --- authentication ----------------------------------------------------
|
||||||
|
t.eq(200, t.socks_http(guarded, origin + "/echo",
|
||||||
|
auth=("alice", "secret")).status,
|
||||||
|
"valid SOCKS5 credentials pass")
|
||||||
|
t.ne(None, t.socks_connect(guarded, "127.0.0.1", srv,
|
||||||
|
auth=("alice", "wrong")),
|
||||||
|
"wrong SOCKS5 credentials are refused")
|
||||||
|
t.ne(None, t.socks_connect(guarded, "127.0.0.1", srv),
|
||||||
|
"SOCKS5 without credentials is refused")
|
||||||
460
tests/harness.py
Normal file
460
tests/harness.py
Normal file
@ -0,0 +1,460 @@
|
|||||||
|
"""Support code for the 3proxy regression tests.
|
||||||
|
|
||||||
|
Everything here is standard library, so the suite runs wherever 3proxy
|
||||||
|
builds: no shell, no curl, no netcat.
|
||||||
|
|
||||||
|
A test case is a module under tests/cases/ exporting run(t). It writes the
|
||||||
|
configurations it needs, starts them, and states what it expects:
|
||||||
|
|
||||||
|
def run(t):
|
||||||
|
srv = t.free_port()
|
||||||
|
t.start("echo", f'''
|
||||||
|
log
|
||||||
|
auth iponly
|
||||||
|
allow *
|
||||||
|
http * /echo echo
|
||||||
|
httpsrv -p{srv}
|
||||||
|
''', ports=[srv])
|
||||||
|
r = t.http(f"http://127.0.0.1:{srv}/echo")
|
||||||
|
t.eq(200, r.status, "the server answers")
|
||||||
|
"""
|
||||||
|
|
||||||
|
import base64
|
||||||
|
import http.client
|
||||||
|
import os
|
||||||
|
import socket
|
||||||
|
import struct
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
import textwrap
|
||||||
|
import time
|
||||||
|
|
||||||
|
|
||||||
|
class Response:
|
||||||
|
"""A reply, or the reason there wasn't one."""
|
||||||
|
|
||||||
|
def __init__(self, status=None, body=b"", headers=None, error=None):
|
||||||
|
self.status = status
|
||||||
|
self.body = body
|
||||||
|
self.headers = headers or {}
|
||||||
|
self.error = error
|
||||||
|
|
||||||
|
@property
|
||||||
|
def text(self):
|
||||||
|
return self.body.decode("utf-8", "replace")
|
||||||
|
|
||||||
|
@property
|
||||||
|
def length(self):
|
||||||
|
return len(self.body)
|
||||||
|
|
||||||
|
def header(self, name):
|
||||||
|
for k, v in self.headers.items():
|
||||||
|
if k.lower() == name.lower():
|
||||||
|
return v
|
||||||
|
return None
|
||||||
|
|
||||||
|
def __repr__(self):
|
||||||
|
if self.error:
|
||||||
|
return f"<no reply: {self.error}>"
|
||||||
|
return f"<{self.status}, {len(self.body)} bytes>"
|
||||||
|
|
||||||
|
|
||||||
|
class Server:
|
||||||
|
"""A running 3proxy, with the configuration it was given."""
|
||||||
|
|
||||||
|
def __init__(self, name, path, proc, logfile):
|
||||||
|
self.name = name
|
||||||
|
self.path = path
|
||||||
|
self.proc = proc
|
||||||
|
self.logfile = logfile
|
||||||
|
|
||||||
|
def output(self):
|
||||||
|
try:
|
||||||
|
with open(self.logfile, "rb") as fp:
|
||||||
|
return fp.read().decode("utf-8", "replace")
|
||||||
|
except OSError:
|
||||||
|
return ""
|
||||||
|
|
||||||
|
def stop(self):
|
||||||
|
if self.proc.poll() is None:
|
||||||
|
self.proc.terminate()
|
||||||
|
try:
|
||||||
|
self.proc.wait(timeout=5)
|
||||||
|
except subprocess.TimeoutExpired:
|
||||||
|
self.proc.kill()
|
||||||
|
self.proc.wait(timeout=5)
|
||||||
|
|
||||||
|
|
||||||
|
class Failure(Exception):
|
||||||
|
"""Raised when a case cannot go on, e.g. a server refused to start."""
|
||||||
|
|
||||||
|
|
||||||
|
class Tester:
|
||||||
|
"""The API a case runs against: start servers, make requests, assert."""
|
||||||
|
|
||||||
|
def __init__(self, binary, tmpdir, case):
|
||||||
|
self.binary = binary
|
||||||
|
self.tmpdir = tmpdir
|
||||||
|
self.case = case
|
||||||
|
self.servers = []
|
||||||
|
self.checks = []
|
||||||
|
self.timeout = 10
|
||||||
|
self._skipped = 0
|
||||||
|
|
||||||
|
# ---- servers -----------------------------------------------------
|
||||||
|
|
||||||
|
def free_port(self):
|
||||||
|
"""A port nothing is listening on. Closed again before it is used,
|
||||||
|
which is racy in principle and reliable enough in practice."""
|
||||||
|
s = socket.socket()
|
||||||
|
try:
|
||||||
|
s.bind(("127.0.0.1", 0))
|
||||||
|
return s.getsockname()[1]
|
||||||
|
finally:
|
||||||
|
s.close()
|
||||||
|
|
||||||
|
def write_config(self, name, config):
|
||||||
|
path = os.path.join(self.tmpdir, name + ".cfg")
|
||||||
|
text = textwrap.dedent(config).strip() + "\n"
|
||||||
|
# newline="" keeps the line endings as written, rather than letting
|
||||||
|
# Windows turn them into CRLF behind the parser's back
|
||||||
|
with open(path, "w", newline="") as fp:
|
||||||
|
fp.write(text)
|
||||||
|
return path
|
||||||
|
|
||||||
|
def start(self, name, config, ports=()):
|
||||||
|
"""Write a configuration, run it, and wait for its ports to open."""
|
||||||
|
path = self.write_config(name, config)
|
||||||
|
logfile = os.path.join(self.tmpdir, name + ".out")
|
||||||
|
with open(logfile, "wb") as out:
|
||||||
|
proc = subprocess.Popen([self.binary, path], stdout=out,
|
||||||
|
stderr=subprocess.STDOUT)
|
||||||
|
server = Server(name, path, proc, logfile)
|
||||||
|
self.servers.append(server)
|
||||||
|
|
||||||
|
for port in ports:
|
||||||
|
if not self.wait_port(port):
|
||||||
|
raise Failure(
|
||||||
|
f"{name} never listened on port {port}\n"
|
||||||
|
f"--- configuration ---\n{open(path).read()}"
|
||||||
|
f"--- output ---\n{server.output()}")
|
||||||
|
return server
|
||||||
|
|
||||||
|
def run_config(self, name, config):
|
||||||
|
"""Run a configuration expected to be rejected; return its output."""
|
||||||
|
path = self.write_config(name, config)
|
||||||
|
done = subprocess.run([self.binary, path], stdout=subprocess.PIPE,
|
||||||
|
stderr=subprocess.STDOUT, timeout=15)
|
||||||
|
return done.stdout.decode("utf-8", "replace")
|
||||||
|
|
||||||
|
def wait_port(self, port, timeout=5.0):
|
||||||
|
deadline = time.time() + timeout
|
||||||
|
while time.time() < deadline:
|
||||||
|
try:
|
||||||
|
with socket.create_connection(("127.0.0.1", port), 0.25):
|
||||||
|
return True
|
||||||
|
except OSError:
|
||||||
|
time.sleep(0.02)
|
||||||
|
return False
|
||||||
|
|
||||||
|
def stop_all(self):
|
||||||
|
for server in self.servers:
|
||||||
|
server.stop()
|
||||||
|
self.servers = []
|
||||||
|
|
||||||
|
# ---- requests ----------------------------------------------------
|
||||||
|
|
||||||
|
def http(self, url, proxy=None, socks=None, socks4=False,
|
||||||
|
remote_dns=False, method="GET", body=None, headers=None,
|
||||||
|
auth=None, proxy_auth=None, tunnel=False, conn=None):
|
||||||
|
"""Make a request, directly or through a proxy, and read the reply.
|
||||||
|
|
||||||
|
proxy "host:port" of an HTTP proxy
|
||||||
|
socks "host:port" of a SOCKS proxy
|
||||||
|
tunnel reach the origin with CONNECT rather than an absolute URI
|
||||||
|
conn reuse a connection returned by connection()
|
||||||
|
"""
|
||||||
|
host, port, path = self._split(url)
|
||||||
|
headers = dict(headers or {})
|
||||||
|
if auth:
|
||||||
|
headers["Authorization"] = self._basic(auth)
|
||||||
|
if proxy_auth:
|
||||||
|
headers["Proxy-Authorization"] = self._basic(proxy_auth)
|
||||||
|
|
||||||
|
own = conn is None
|
||||||
|
try:
|
||||||
|
if own:
|
||||||
|
conn = self.connection(host, port, proxy=proxy, socks=socks,
|
||||||
|
socks4=socks4, remote_dns=remote_dns,
|
||||||
|
tunnel=tunnel)
|
||||||
|
target = path
|
||||||
|
if proxy and not tunnel:
|
||||||
|
target = f"http://{host}:{port}{path}"
|
||||||
|
if body is not None and not isinstance(body, bytes):
|
||||||
|
body = body.encode()
|
||||||
|
conn.request(method, target, body=body, headers=headers)
|
||||||
|
reply = conn.getresponse()
|
||||||
|
data = reply.read()
|
||||||
|
return Response(reply.status, data, dict(reply.getheaders()))
|
||||||
|
except (OSError, http.client.HTTPException) as exc:
|
||||||
|
return Response(error=f"{type(exc).__name__}: {exc}")
|
||||||
|
finally:
|
||||||
|
if own and conn is not None:
|
||||||
|
try:
|
||||||
|
conn.close()
|
||||||
|
except OSError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
def connection(self, host, port, proxy=None, socks=None, socks4=False,
|
||||||
|
remote_dns=False, tunnel=False):
|
||||||
|
"""A connection to an origin, kept open for reuse."""
|
||||||
|
if socks:
|
||||||
|
shost, sport = self._hostport(socks)
|
||||||
|
sock = self._socks_connect(shost, sport, host, port,
|
||||||
|
socks4=socks4, remote_dns=remote_dns)
|
||||||
|
conn = http.client.HTTPConnection(host, port, timeout=self.timeout)
|
||||||
|
conn.sock = sock
|
||||||
|
return conn
|
||||||
|
if proxy:
|
||||||
|
phost, pport = self._hostport(proxy)
|
||||||
|
conn = http.client.HTTPConnection(phost, pport, timeout=self.timeout)
|
||||||
|
if tunnel:
|
||||||
|
conn.set_tunnel(host, port)
|
||||||
|
return conn
|
||||||
|
return http.client.HTTPConnection(host, port, timeout=self.timeout)
|
||||||
|
|
||||||
|
def raw(self, port, request, host="127.0.0.1"):
|
||||||
|
"""Send bytes as they are and return whatever comes back."""
|
||||||
|
if not isinstance(request, bytes):
|
||||||
|
request = request.encode("latin-1")
|
||||||
|
try:
|
||||||
|
with socket.create_connection((host, port), self.timeout) as sock:
|
||||||
|
sock.settimeout(self.timeout)
|
||||||
|
sock.sendall(request)
|
||||||
|
chunks = []
|
||||||
|
while True:
|
||||||
|
try:
|
||||||
|
piece = sock.recv(65536)
|
||||||
|
except OSError:
|
||||||
|
# a timeout, or a reset once the server is done:
|
||||||
|
# either way keep whatever already arrived
|
||||||
|
break
|
||||||
|
if not piece:
|
||||||
|
break
|
||||||
|
chunks.append(piece)
|
||||||
|
return b"".join(chunks).decode("utf-8", "replace")
|
||||||
|
except OSError as exc:
|
||||||
|
return f"<no reply: {exc}>"
|
||||||
|
|
||||||
|
# ---- SOCKS -------------------------------------------------------
|
||||||
|
|
||||||
|
def _socks_connect(self, shost, sport, host, port, socks4=False,
|
||||||
|
remote_dns=False, auth=None):
|
||||||
|
sock = socket.create_connection((shost, sport), self.timeout)
|
||||||
|
sock.settimeout(self.timeout)
|
||||||
|
try:
|
||||||
|
if socks4:
|
||||||
|
addr = socket.inet_aton(socket.gethostbyname(host))
|
||||||
|
sock.sendall(b"\x04\x01" + struct.pack("!H", port) + addr + b"\x00")
|
||||||
|
reply = self._recvall(sock, 8)
|
||||||
|
if len(reply) < 2 or reply[1] != 0x5a:
|
||||||
|
raise OSError("SOCKS4 request refused")
|
||||||
|
return sock
|
||||||
|
|
||||||
|
if auth:
|
||||||
|
sock.sendall(b"\x05\x02\x00\x02")
|
||||||
|
else:
|
||||||
|
sock.sendall(b"\x05\x01\x00")
|
||||||
|
reply = self._recvall(sock, 2)
|
||||||
|
if len(reply) < 2 or reply[0] != 5:
|
||||||
|
raise OSError("SOCKS5 handshake failed")
|
||||||
|
if reply[1] == 0x02:
|
||||||
|
if not auth:
|
||||||
|
raise OSError("SOCKS5 server demands credentials")
|
||||||
|
user, password = auth
|
||||||
|
sock.sendall(b"\x01" + bytes([len(user)]) + user.encode() +
|
||||||
|
bytes([len(password)]) + password.encode())
|
||||||
|
status = self._recvall(sock, 2)
|
||||||
|
if len(status) < 2 or status[1] != 0:
|
||||||
|
raise OSError("SOCKS5 credentials refused")
|
||||||
|
elif reply[1] != 0x00:
|
||||||
|
raise OSError("SOCKS5 offered no acceptable method")
|
||||||
|
|
||||||
|
if remote_dns:
|
||||||
|
target = b"\x03" + bytes([len(host)]) + host.encode()
|
||||||
|
else:
|
||||||
|
target = b"\x01" + socket.inet_aton(socket.gethostbyname(host))
|
||||||
|
sock.sendall(b"\x05\x01\x00" + target + struct.pack("!H", port))
|
||||||
|
reply = self._recvall(sock, 4)
|
||||||
|
if len(reply) < 4 or reply[1] != 0:
|
||||||
|
raise OSError("SOCKS5 request refused")
|
||||||
|
self._read_socks_addr(sock, reply[3])
|
||||||
|
return sock
|
||||||
|
except Exception:
|
||||||
|
sock.close()
|
||||||
|
raise
|
||||||
|
|
||||||
|
def socks_connect(self, socks, host, port, socks4=False, remote_dns=False,
|
||||||
|
auth=None):
|
||||||
|
"""Open a SOCKS connection, reporting failure rather than raising."""
|
||||||
|
shost, sport = self._hostport(socks)
|
||||||
|
try:
|
||||||
|
sock = self._socks_connect(shost, sport, host, port, socks4=socks4,
|
||||||
|
remote_dns=remote_dns, auth=auth)
|
||||||
|
sock.close()
|
||||||
|
return None
|
||||||
|
except OSError as exc:
|
||||||
|
return str(exc)
|
||||||
|
|
||||||
|
def socks_http(self, socks, url, auth=None, **kwargs):
|
||||||
|
"""A request through SOCKS, with optional SOCKS credentials."""
|
||||||
|
host, port, path = self._split(url)
|
||||||
|
shost, sport = self._hostport(socks)
|
||||||
|
try:
|
||||||
|
sock = self._socks_connect(shost, sport, host, port, auth=auth,
|
||||||
|
**kwargs)
|
||||||
|
except OSError as exc:
|
||||||
|
return Response(error=str(exc))
|
||||||
|
conn = http.client.HTTPConnection(host, port, timeout=self.timeout)
|
||||||
|
conn.sock = sock
|
||||||
|
try:
|
||||||
|
conn.request("GET", path)
|
||||||
|
reply = conn.getresponse()
|
||||||
|
return Response(reply.status, reply.read(), dict(reply.getheaders()))
|
||||||
|
except (OSError, http.client.HTTPException) as exc:
|
||||||
|
return Response(error=str(exc))
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
|
def socks_udp_associate(self, port, host="127.0.0.1"):
|
||||||
|
"""Ask for a UDP association and report the port handed back.
|
||||||
|
|
||||||
|
That socket is allocated per association, which is where an intport
|
||||||
|
range has to take effect.
|
||||||
|
"""
|
||||||
|
try:
|
||||||
|
with socket.create_connection((host, port), self.timeout) as sock:
|
||||||
|
sock.settimeout(self.timeout)
|
||||||
|
sock.sendall(b"\x05\x01\x00")
|
||||||
|
if self._recvall(sock, 2) != b"\x05\x00":
|
||||||
|
return None
|
||||||
|
sock.sendall(b"\x05\x03\x00\x01\x00\x00\x00\x00" +
|
||||||
|
struct.pack("!H", 0))
|
||||||
|
reply = self._recvall(sock, 4)
|
||||||
|
if len(reply) < 4 or reply[1] != 0:
|
||||||
|
return None
|
||||||
|
_, bound = self._read_socks_addr(sock, reply[3])
|
||||||
|
return bound
|
||||||
|
except OSError:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def _read_socks_addr(self, sock, atyp):
|
||||||
|
if atyp == 1:
|
||||||
|
addr = socket.inet_ntoa(self._recvall(sock, 4))
|
||||||
|
elif atyp == 3:
|
||||||
|
length = self._recvall(sock, 1)[0]
|
||||||
|
addr = self._recvall(sock, length).decode()
|
||||||
|
elif atyp == 4:
|
||||||
|
addr = self._recvall(sock, 16).hex()
|
||||||
|
else:
|
||||||
|
raise OSError(f"unknown SOCKS address type {atyp}")
|
||||||
|
port = struct.unpack("!H", self._recvall(sock, 2))[0]
|
||||||
|
return addr, port
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _recvall(sock, count):
|
||||||
|
data = b""
|
||||||
|
while len(data) < count:
|
||||||
|
piece = sock.recv(count - len(data))
|
||||||
|
if not piece:
|
||||||
|
break
|
||||||
|
data += piece
|
||||||
|
return data
|
||||||
|
|
||||||
|
# ---- helpers -----------------------------------------------------
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _basic(credentials):
|
||||||
|
user, password = credentials
|
||||||
|
token = base64.b64encode(f"{user}:{password}".encode()).decode()
|
||||||
|
return "Basic " + token
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _hostport(value):
|
||||||
|
host, _, port = value.rpartition(":")
|
||||||
|
return host or "127.0.0.1", int(port)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _split(url):
|
||||||
|
prefix = "http://"
|
||||||
|
if url.startswith(prefix):
|
||||||
|
url = url[len(prefix):]
|
||||||
|
authority, _, path = url.partition("/")
|
||||||
|
host, _, port = authority.rpartition(":")
|
||||||
|
return host or "127.0.0.1", int(port), "/" + path
|
||||||
|
|
||||||
|
# ---- assertions --------------------------------------------------
|
||||||
|
|
||||||
|
def _record(self, passed, label, expected=None, actual=None):
|
||||||
|
self.checks.append((passed, label, expected, actual))
|
||||||
|
return passed
|
||||||
|
|
||||||
|
def ok(self, label):
|
||||||
|
return self._record(True, label)
|
||||||
|
|
||||||
|
def fail(self, label, expected=None, actual=None):
|
||||||
|
return self._record(False, label, expected, actual)
|
||||||
|
|
||||||
|
def eq(self, expected, actual, label):
|
||||||
|
return self._record(expected == actual, label, expected, actual)
|
||||||
|
|
||||||
|
def ne(self, unexpected, actual, label):
|
||||||
|
return self._record(unexpected != actual, label,
|
||||||
|
f"anything but {unexpected!r}", actual)
|
||||||
|
|
||||||
|
def contains(self, haystack, needle, label):
|
||||||
|
if isinstance(haystack, Response):
|
||||||
|
haystack = haystack.text
|
||||||
|
return self._record(needle in haystack, label,
|
||||||
|
f"text containing {needle!r}", self._clip(haystack))
|
||||||
|
|
||||||
|
def not_contains(self, haystack, needle, label):
|
||||||
|
if isinstance(haystack, Response):
|
||||||
|
haystack = haystack.text
|
||||||
|
return self._record(needle not in haystack, label,
|
||||||
|
f"text without {needle!r}", self._clip(haystack))
|
||||||
|
|
||||||
|
def in_range(self, value, low, high, label):
|
||||||
|
good = isinstance(value, int) and low <= value <= high
|
||||||
|
return self._record(good, label, f"between {low} and {high}", value)
|
||||||
|
|
||||||
|
def not_in_range(self, value, low, high, label):
|
||||||
|
good = isinstance(value, int) and not (low <= value <= high)
|
||||||
|
return self._record(good, label, f"outside {low}-{high}", value)
|
||||||
|
|
||||||
|
def skip(self, label):
|
||||||
|
self._skipped += 1
|
||||||
|
self.checks.append((None, label, None, None))
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _clip(text, limit=200):
|
||||||
|
text = str(text).replace("\r\n", " ").replace("\n", " ")
|
||||||
|
return text[:limit] + ("..." if len(text) > limit else "")
|
||||||
|
|
||||||
|
|
||||||
|
def field(response, name):
|
||||||
|
"""Pull one 'key=value' line out of an echo reply."""
|
||||||
|
text = response.text if isinstance(response, Response) else response
|
||||||
|
for line in text.splitlines():
|
||||||
|
key, _, value = line.partition("=")
|
||||||
|
if key == name:
|
||||||
|
return value
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def int_field(response, name):
|
||||||
|
value = field(response, name)
|
||||||
|
try:
|
||||||
|
return int(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
141
tests/run.py
Normal file
141
tests/run.py
Normal file
@ -0,0 +1,141 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""Run the 3proxy regression tests.
|
||||||
|
|
||||||
|
python3 tests/run.py every case
|
||||||
|
python3 tests/run.py httpsrv cases whose name matches
|
||||||
|
python3 tests/run.py --bin build/bin/3proxy
|
||||||
|
python3 tests/run.py --keep leave the temporary files behind
|
||||||
|
|
||||||
|
Each case under tests/cases/ defines the configurations it needs and the
|
||||||
|
positive and negative scenarios expected from them.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import argparse
|
||||||
|
import importlib.util
|
||||||
|
import os
|
||||||
|
import shutil
|
||||||
|
import sys
|
||||||
|
import tempfile
|
||||||
|
import traceback
|
||||||
|
|
||||||
|
sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
from harness import Failure, Tester # noqa: E402
|
||||||
|
|
||||||
|
ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
||||||
|
|
||||||
|
|
||||||
|
def default_binary():
|
||||||
|
"""Find a built 3proxy: the Makefiles put it in bin/, CMake in build/bin/,
|
||||||
|
and multi-configuration generators one level below that again."""
|
||||||
|
name = "3proxy.exe" if os.name == "nt" else "3proxy"
|
||||||
|
candidates = [os.path.join(ROOT, "bin", name),
|
||||||
|
os.path.join(ROOT, "build", "bin", name)]
|
||||||
|
for config in ("Release", "Debug", "RelWithDebInfo", "MinSizeRel"):
|
||||||
|
candidates.append(os.path.join(ROOT, "build", "bin", config, name))
|
||||||
|
for candidate in candidates:
|
||||||
|
if os.path.isfile(candidate):
|
||||||
|
return candidate
|
||||||
|
return candidates[0]
|
||||||
|
|
||||||
|
|
||||||
|
def load_case(path):
|
||||||
|
name = os.path.splitext(os.path.basename(path))[0]
|
||||||
|
spec = importlib.util.spec_from_file_location("case_" + name, path)
|
||||||
|
module = importlib.util.module_from_spec(spec)
|
||||||
|
spec.loader.exec_module(module)
|
||||||
|
return name, module
|
||||||
|
|
||||||
|
|
||||||
|
def main():
|
||||||
|
parser = argparse.ArgumentParser()
|
||||||
|
parser.add_argument("pattern", nargs="?", default="",
|
||||||
|
help="only run cases whose name contains this")
|
||||||
|
parser.add_argument("--bin", dest="binary", default=None,
|
||||||
|
help="the 3proxy binary to test")
|
||||||
|
parser.add_argument("--keep", action="store_true",
|
||||||
|
help="keep the temporary directory")
|
||||||
|
parser.add_argument("-v", "--verbose", action="store_true",
|
||||||
|
help="print every check, not just the failures")
|
||||||
|
args = parser.parse_args()
|
||||||
|
|
||||||
|
binary = args.binary or os.environ.get("BIN") or default_binary()
|
||||||
|
binary = os.path.abspath(binary)
|
||||||
|
if not os.path.isfile(binary):
|
||||||
|
print(f"no 3proxy binary at {binary} (build first, or pass --bin)",
|
||||||
|
file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
case_dir = os.path.join(ROOT, "tests", "cases")
|
||||||
|
paths = sorted(os.path.join(case_dir, f) for f in os.listdir(case_dir)
|
||||||
|
if f.endswith(".py") and not f.startswith("_"))
|
||||||
|
paths = [p for p in paths if args.pattern in os.path.basename(p)]
|
||||||
|
if not paths:
|
||||||
|
print(f"no cases matched {args.pattern!r}", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
|
||||||
|
tmpdir = tempfile.mkdtemp(prefix="3proxy-tests.")
|
||||||
|
print(f"3proxy tests: {binary}")
|
||||||
|
print(f"working in: {tmpdir}\n")
|
||||||
|
|
||||||
|
passed = failed = skipped = 0
|
||||||
|
failures = []
|
||||||
|
|
||||||
|
try:
|
||||||
|
for path in paths:
|
||||||
|
name, module = load_case(path)
|
||||||
|
print(f" {name}")
|
||||||
|
tester = Tester(binary, tmpdir, name)
|
||||||
|
error = None
|
||||||
|
try:
|
||||||
|
module.run(tester)
|
||||||
|
except Failure as exc:
|
||||||
|
error = str(exc)
|
||||||
|
except Exception:
|
||||||
|
error = traceback.format_exc()
|
||||||
|
finally:
|
||||||
|
tester.stop_all()
|
||||||
|
|
||||||
|
for status, label, expected, actual in tester.checks:
|
||||||
|
if status is None:
|
||||||
|
skipped += 1
|
||||||
|
print(f" skip {label}")
|
||||||
|
elif status:
|
||||||
|
passed += 1
|
||||||
|
if args.verbose:
|
||||||
|
print(f" ok {label}")
|
||||||
|
else:
|
||||||
|
failed += 1
|
||||||
|
failures.append(f"{name}: {label}")
|
||||||
|
print(f" FAIL {label}")
|
||||||
|
if expected is not None:
|
||||||
|
print(f" expected: {expected}")
|
||||||
|
if actual is not None:
|
||||||
|
print(f" actual: {actual}")
|
||||||
|
|
||||||
|
if error:
|
||||||
|
failed += 1
|
||||||
|
failures.append(f"{name}: case aborted")
|
||||||
|
print(" ERROR the case could not finish:")
|
||||||
|
for line in error.rstrip().splitlines():
|
||||||
|
print(f" {line}")
|
||||||
|
print()
|
||||||
|
finally:
|
||||||
|
if args.keep:
|
||||||
|
print(f"temporary files left in {tmpdir}")
|
||||||
|
else:
|
||||||
|
shutil.rmtree(tmpdir, ignore_errors=True)
|
||||||
|
|
||||||
|
print("-" * 41)
|
||||||
|
total = passed + failed
|
||||||
|
summary = f"cases: {len(paths)} checks: {total} passed: {passed} failed: {failed}"
|
||||||
|
if skipped:
|
||||||
|
summary += f" skipped: {skipped}"
|
||||||
|
print(summary)
|
||||||
|
for item in failures:
|
||||||
|
print(f" FAIL {item}")
|
||||||
|
return 1 if failed else 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main())
|
||||||
Loading…
Reference in New Issue
Block a user