import asyncio
import os
import pathlib
import socket
import stat
import tempfile
import pytest
import soyokaze
from soyokaze import Client, ClientConfig, Compression, Limits, Message, Method, Port, Server, ServerConfig, ServerLimits, URL, Version
async def echo(request):
response = Message.text(request.target or "")
response.insert_header("x-answered-by", "python")
if request.header("x-probe") is not None:
response.insert_header("x-probe-echo", request.header("x-probe"))
return response
async def serve(handler=echo, on_websocket=None, config=None):
server = Server(config)
handle = await server.serve(handler, [Port.TCP(0)], on_websocket=on_websocket)
assert handle.port != 0, "a port of zero must report the one the kernel chose"
return server, handle, f"http://127.0.0.1:{handle.port}"
async def test_a_request_crosses_to_the_handler_and_its_answer_crosses_back():
server, handle, origin = await serve()
async with handle:
client = Client()
response = await client.fetch(Method.GET, f"{origin}/hello", headers=[("x-probe", "sent")])
assert response.status_code == 200
assert response.header("x-answered-by") == "python"
assert response.header("x-probe-echo") == "sent"
assert await response.body() == b"/hello"
async def test_every_shorthand_reaches_the_server():
seen = []
async def record(request):
seen.append((request.method, await request.body()))
return Message.text("ok")
server, handle, origin = await serve(record)
try:
client = Client()
await client.get(f"{origin}/")
await client.head(f"{origin}/")
await client.post(f"{origin}/", b"data")
await client.put(f"{origin}/", "text")
await client.delete(f"{origin}/")
assert [method for method, body in seen] == [Method.GET, Method.HEAD, Method.POST, Method.PUT, Method.DELETE]
assert seen[2][1] == b"data"
assert seen[3][1] == b"text"
finally:
await handle.close(5)
async def test_a_plain_handler_is_served_without_ever_reaching_the_loop():
def synchronous(request):
return Message.text("plain")
server, handle, origin = await serve(synchronous)
try:
assert await (await Client().get(f"{origin}/")).body() == b"plain"
finally:
await handle.close(5)
async def test_a_handler_that_raises_answers_with_a_bare_500(capsys):
async def broken(request):
raise ValueError("deliberate")
server, handle, origin = await serve(broken)
try:
response = await Client().get(f"{origin}/")
assert response.status_code == 500
assert "deliberate" in capsys.readouterr().err, "the traceback must not vanish"
finally:
await handle.close(5)
async def test_several_messages_go_over_one_connection():
server, handle, origin = await serve()
try:
client = Client(ClientConfig(secure=False))
async with await client.connect("127.0.0.1", Port.TCP(handle.port)) as connection:
assert connection.version == Version.V1_1
assert connection.role == soyokaze.Role.USER_AGENT
assert connection.role.is_client(), "a connection the client opened sends requests"
for index in range(3):
response = await client.request(connection, Message.request(Method.GET, f"/turn/{index}"))
assert await response.body() == f"/turn/{index}".encode()
assert connection.reusable()
finally:
await handle.close(5)
async def test_requests_awaited_together_do_not_wait_for_one_another():
started = asyncio.Event()
async def slow(request):
if request.target == "/slow":
started.set()
await asyncio.sleep(0.2)
return Message.text(request.target or "")
server, handle, origin = await serve(slow)
try:
client = Client()
slow_request = asyncio.create_task(client.get(f"{origin}/slow"))
await asyncio.wait_for(started.wait(), 5)
quick = await asyncio.wait_for(client.get(f"{origin}/quick"), 5)
assert await quick.body() == b"/quick", "a second request must not queue behind the first"
assert await (await slow_request).body() == b"/slow"
finally:
await handle.close(5)
async def test_send_and_receive_expose_the_raw_exchange():
server, handle, origin = await serve()
try:
client = Client(ClientConfig(secure=False))
async with await client.open(URL(origin)) as connection:
await connection.send(Message.request(Method.GET, "/raw"))
response = await connection.receive()
assert response.status_code == 200
assert await response.body() == b"/raw"
finally:
await handle.close(5)
async def test_a_pinned_version_and_ceilinged_limits_still_serve():
limits = ServerLimits(message=Limits(max_header_count=32), max_connections=16)
config = ServerConfig(versions=[Version.V1_1], limits=limits)
server, handle, origin = await serve(config=config)
try:
client = Client(ClientConfig(versions=[Version.V1_1]))
assert (await client.get(f"{origin}/pinned")).status_code == 200
finally:
await handle.close(5)
async def test_the_cluster_spreads_the_same_server_across_workers():
server = Server()
async with await server.run(echo, [Port.TCP(0)], workers=2) as cluster:
assert cluster.workers() == 2
assert cluster.port != 0
response = await Client().get(f"http://127.0.0.1:{cluster.port}/clustered")
assert await response.body() == b"/clustered"
async def test_the_websocket_callback_runs_the_socket_both_ways():
received = []
async def on_websocket(socket):
opcode, payload = await socket.receive_message()
received.append((opcode, payload))
await socket.send_message(opcode, payload.decode().upper())
await socket.close(soyokaze.CloseCode.NORMAL, "done")
server, handle, origin = await serve(on_websocket=on_websocket)
try:
client = Client()
socket = await client.websocket(f"ws://127.0.0.1:{handle.port}/chat")
await socket.send_message(soyokaze.Opcode.TEXT, "hello")
opcode, payload = await socket.receive_message()
assert (opcode, payload) == (soyokaze.Opcode.TEXT, b"HELLO"), "receive_message hands back what send_message takes, in that order"
opcode, payload = await socket.receive_message()
assert opcode == soyokaze.Opcode.CLOSE, "the server's close reaches the client"
assert received == [(soyokaze.Opcode.TEXT, b"hello")]
finally:
await handle.close(5)
async def test_the_client_keeps_cookies_across_requests():
async def set_then_expect(request):
response = Message.text("ok")
if request.target == "/set":
response.set_cookie(soyokaze.SetCookie("sid", "abc"))
else:
response.insert_header("x-got-cookie", request.header("cookie") or "none")
return response
server, handle, origin = await serve(set_then_expect)
try:
client = Client()
await client.get(f"{origin}/set")
response = await client.get(f"{origin}/again")
assert response.header("x-got-cookie") == "sid=abc"
stateless = Client(ClientConfig(cookies=False))
await stateless.get(f"{origin}/set")
response = await stateless.get(f"{origin}/again")
assert response.header("x-got-cookie") == "none"
finally:
await handle.close(5)
async def test_dialling_nothing_raises_rather_than_hanging():
client = Client()
with pytest.raises(soyokaze.Error):
await client.get("http://127.0.0.1:1/")
async def test_a_served_response_is_compressed_when_the_request_accepts_it():
body = "a" * 8192
seen = {}
async def compressing(request):
seen["accept"] = request.header("accept-encoding")
seen["client"] = request.client
response = Message.text(body)
response.compression = Compression.AUTO
return response
server, handle, origin = await serve(compressing)
async with handle:
client = Client()
response = await client.get(f"{origin}/")
assert seen["accept"] == Compression.accepted_field()
assert response.compression is Compression.ZSTD
assert not response.compressed()
assert response.header("content-encoding") is None
assert response.header("vary") == "Accept-Encoding"
assert (await response.body()).decode() == body
async def test_a_request_that_accepts_nothing_is_answered_uncoded():
async def compressing(request):
response = Message.text("a" * 8192)
response.compression = Compression.AUTO
return response
server, handle, origin = await serve(compressing)
async with handle:
client = Client()
response = await client.fetch(Method.GET, f"{origin}/", headers=[("accept-encoding", "identity")])
assert response.compression is None, "a peer that accepts nothing gets the body as it stands"
assert not response.compressed()
assert response.header("content-length") == "8192"
async def test_the_handler_sees_the_address_the_request_came_from():
seen = {}
async def record(request):
seen["client"] = request.client
return Message.text("ok")
server, handle, origin = await serve(record)
async with handle:
client = Client()
await client.get(f"{origin}/")
address = seen["client"]
assert address is not None, "a handler must be told where the request came from"
assert address.rsplit(":", 1)[-1].isdigit(), f"{address!r} must carry the port the peer dialled from"
async def test_a_response_a_client_receives_names_no_access_source():
server, handle, origin = await serve()
async with handle:
client = Client()
response = await client.get(f"{origin}/")
assert response.client is None, "a response names no access source"
async def serve_uds(name, config=None):
path = pathlib.Path(tempfile.gettempdir(), f"soyokaze-{name}-{os.getpid()}.sock")
path.unlink(missing_ok=True)
server = Server(config)
handle = await server.serve(echo, [Port.UDS(path)])
return server, handle, path
async def test_a_unix_socket_is_bound_at_the_default_mode():
server, handle, path = await serve_uds("default")
try:
assert server.uds_mode == 0o666, "a server must report the mode it binds a unix socket at"
assert stat.S_IMODE(path.stat().st_mode) == 0o666, "connecting asks for write permission, which every peer must have by default"
reader, writer = await asyncio.open_unix_connection(str(path))
writer.write(b"GET /hello HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n")
await writer.drain()
response = await asyncio.wait_for(reader.read(), 5)
writer.close()
assert response.startswith(b"HTTP/1.1 200"), f"the socket must serve the request it was dialled with: {response[:32]!r}"
assert response.endswith(b"/hello")
finally:
await handle.close()
path.unlink(missing_ok=True)
async def test_a_unix_socket_is_bound_at_the_configured_mode():
server, handle, path = await serve_uds("configured", ServerConfig(uds_mode=0o600))
try:
assert server.uds_mode == 0o600
assert stat.S_IMODE(path.stat().st_mode) == 0o600, "a unix socket must be bound at the mode it was configured with"
finally:
await handle.close()
path.unlink(missing_ok=True)
async def test_a_unix_socket_mode_of_zero_leaves_the_umask_its_say():
bare = pathlib.Path(tempfile.gettempdir(), f"soyokaze-bare-{os.getpid()}.sock")
bare.unlink(missing_ok=True)
listener = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
listener.bind(str(bare))
server, handle, path = await serve_uds("umask", ServerConfig(uds_mode=0))
try:
assert server.uds_mode == 0
assert stat.S_IMODE(path.stat().st_mode) == stat.S_IMODE(bare.stat().st_mode), "a mode of zero must leave the socket as the umask made it"
finally:
await handle.close()
listener.close()
path.unlink(missing_ok=True)
bare.unlink(missing_ok=True)