Files
2026-07-13 12:32:21 +08:00

508 lines
14 KiB
Lua

------------------------------------------------------------------
-- Collection of utilities to help testing Kong features and plugins.
--
-- @copyright Copyright 2016-2022 Kong Inc. All rights reserved.
-- @license [Apache 2.0](https://opensource.org/licenses/Apache-2.0)
-- @module spec.helpers
local CONSTANTS = require("spec.internal.constants")
---
-- TCP/UDP server helpers
--
-- @section servers
--- Starts a local TCP server.
-- Accepts a single connection (or multiple, if given `opts.requests`)
-- and then closes, echoing what was received (last read, in case
-- of multiple requests).
-- @function tcp_server
-- @tparam number port The port where the server will be listening on
-- @tparam[opt] table opts options defining the server's behavior with the following fields:
-- @tparam[opt=60] number opts.timeout time (in seconds) after which the server exits
-- @tparam[opt=1] number opts.requests the number of requests to accept before exiting
-- @tparam[opt=false] bool opts.tls make it a TLS server if truthy
-- @tparam[opt] string opts.prefix a prefix to add to the echoed data received
-- @return A thread object (from the `llthreads2` Lua package)
-- @see kill_tcp_server
local function tcp_server(port, opts)
local threads = require "llthreads2.ex"
opts = opts or {}
if CONSTANTS.TEST_COVERAGE_MODE == "true" then
opts.timeout = CONSTANTS.TEST_COVERAGE_TIMEOUT
end
local thread = threads.new({
function(port, opts)
local socket = require "socket"
local server = assert(socket.tcp())
server:settimeout(opts.timeout or 60)
assert(server:setoption("reuseaddr", true))
assert(server:bind("*", port))
assert(server:listen())
local line
local oks, fails = 0, 0
local handshake_done = false
local n = opts.requests or 1
for _ = 1, n + 1 do
local client, err
if opts.timeout then
client, err = server:accept()
if err == "timeout" then
line = "timeout"
break
else
assert(client, err)
end
else
client = assert(server:accept())
end
if opts.tls and handshake_done then
local ssl = require "spec.internal.ssl"
local params = {
mode = "server",
protocol = "any",
key = "spec/fixtures/kong_spec.key",
certificate = "spec/fixtures/kong_spec.crt",
}
client = ssl.wrap(client, params)
client:dohandshake()
end
line, err = client:receive()
if err == "closed" then
fails = fails + 1
else
if not handshake_done then
assert(line == "\\START")
client:send("\\OK\n")
handshake_done = true
else
if line == "@DIE@" then
client:send(string.format("%d:%d\n", oks, fails))
client:close()
break
end
oks = oks + 1
client:send((opts.prefix or "") .. line .. "\n")
end
client:close()
end
end
server:close()
return line
end
}, port, opts)
local thr = thread:start()
-- not necessary for correctness because we do the handshake,
-- but avoids harmless "connection error" messages in the wait loop
-- in case the client is ready before the server below.
ngx.sleep(0.001)
local sock = ngx.socket.tcp()
sock:settimeout(0.01)
while true do
if sock:connect("localhost", port) then
sock:send("\\START\n")
local ok = sock:receive()
sock:close()
if ok == "\\OK" then
break
end
end
end
sock:close()
return thr
end
--- Stops a local TCP server.
-- A server previously created with `tcp_server` can be stopped prematurely by
-- calling this function.
-- @function kill_tcp_server
-- @param port the port the TCP server is listening on.
-- @return oks, fails; the number of successes and failures processed by the server
-- @see tcp_server
local function kill_tcp_server(port)
local sock = ngx.socket.tcp()
assert(sock:connect("localhost", port))
assert(sock:send("@DIE@\n"))
local str = assert(sock:receive())
assert(sock:close())
local oks, fails = str:match("(%d+):(%d+)")
return tonumber(oks), tonumber(fails)
end
local code_status = {
[200] = "OK",
[201] = "Created",
[202] = "Accepted",
[203] = "Non-Authoritative Information",
[204] = "No Content",
[205] = "Reset Content",
[206] = "Partial Content",
[207] = "Multi-Status",
[300] = "Multiple Choices",
[301] = "Moved Permanently",
[302] = "Found",
[303] = "See Other",
[304] = "Not Modified",
[305] = "Use Proxy",
[307] = "Temporary Redirect",
[308] = "Permanent Redirect",
[400] = "Bad Request",
[401] = "Unauthorized",
[402] = "Payment Required",
[403] = "Forbidden",
[404] = "Not Found",
[405] = "Method Not Allowed",
[406] = "Not Acceptable",
[407] = "Proxy Authentication Required",
[408] = "Request Timeout",
[409] = "Conflict",
[410] = "Gone",
[411] = "Length Required",
[412] = "Precondition Failed",
[413] = "Payload Too Large",
[414] = "URI Too Long",
[415] = "Unsupported Media Type",
[416] = "Range Not Satisfiable",
[417] = "Expectation Failed",
[418] = "I'm a teapot",
[422] = "Unprocessable Entity",
[423] = "Locked",
[424] = "Failed Dependency",
[426] = "Upgrade Required",
[428] = "Precondition Required",
[429] = "Too Many Requests",
[431] = "Request Header Fields Too Large",
[451] = "Unavailable For Legal Reasons",
[500] = "Internal Server Error",
[501] = "Not Implemented",
[502] = "Bad Gateway",
[503] = "Service Unavailable",
[504] = "Gateway Timeout",
[505] = "HTTP Version Not Supported",
[506] = "Variant Also Negotiates",
[507] = "Insufficient Storage",
[508] = "Loop Detected",
[510] = "Not Extended",
[511] = "Network Authentication Required",
}
local EMPTY = {}
local function handle_response(code, body, headers)
if not code then
code = 500
body = ""
headers = EMPTY
end
local head_str = ""
for k, v in pairs(headers or EMPTY) do
head_str = head_str .. k .. ": " .. v .. "\r\n"
end
return code .. " " .. code_status[code] .. " HTTP/1.1" .. "\r\n" ..
"Content-Length: " .. #body .. "\r\n" ..
"Connection: close\r\n" ..
head_str ..
"\r\n" ..
body
end
local function handle_request(client, response)
local lines = {}
local headers = {}
local line, err
local content_length
repeat
line, err = client:receive("*l")
if err then
return nil, err
else
local k, v = line:match("^([^:]+):%s*(.+)$")
if k then
headers[k] = v
if k:lower() == "content-length" then
content_length = tonumber(v)
end
end
table.insert(lines, line)
end
until line == ""
local method = lines[1]:match("^(%S+)%s+(%S+)%s+(%S+)$")
local method_lower = method:lower()
local body
if content_length then
body = client:receive(content_length)
elseif method_lower == "put" or method_lower == "post" then
body = client:receive("*a")
end
local response_str
local meta = getmetatable(response)
if type(response) == "function" or (meta and meta.__call) then
response_str = response(lines, body, headers)
elseif type(response) == "table" and response.code then
response_str = handle_response(response.code, response.body, response.headers)
elseif type(response) == "table" and response[1] then
response_str = handle_response(response[1], response[2], response[3])
elseif type(response) == "string" then
response_str = response
elseif response == nil then
response_str = "HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n"
end
client:send(response_str)
return lines, body, headers
end
--- Start a local HTTP server with coroutine.
--
-- **DEPRECATED**: please use `spec.helpers.http_mock` instead.
--
-- local mock = helpers.http_mock(1234, { timeout = 0.1 })
-- wait for a request, and respond with the custom response
-- the request is returned as the function's return values
-- return nil, err if error
-- local lines, body, headers = mock(custom_response)
-- local lines, body, headers = mock()
-- mock("closing", true) -- close the server
local function http_mock(port, opts)
local socket = require "socket"
local server = assert(socket.tcp())
if CONSTANTS.TEST_COVERAGE_MODE == "true" then
opts.timeout = CONSTANTS.TEST_COVERAGE_TIMEOUT
end
server:settimeout(opts and opts.timeout or 60)
assert(server:setoption('reuseaddr', true))
assert(server:bind("*", port))
assert(server:listen())
return coroutine.wrap(function(response, exit)
local lines, body, headers
-- start listening
while not exit do
local client, err = server:accept()
if err then
lines, body = false, err
else
lines, body, headers = handle_request(client, response)
client:close()
end
response, exit = coroutine.yield(lines, body, headers)
end
server:close()
return true
end)
end
--- Starts a local UDP server.
-- Reads the specified number of packets and then closes.
-- The server-thread return values depend on `n`:
--
-- * `n = 1`; returns the received packet (string), or `nil + err`
--
-- * `n > 1`; returns `data + err`, where `data` will always be a table with the
-- received packets. So `err` must explicitly be checked for errors.
-- @function udp_server
-- @tparam[opt] number port The port the server will be listening on, default: `MOCK_UPSTREAM_PORT`
-- @tparam[opt=1] number n The number of packets that will be read
-- @tparam[opt=360] number timeout Timeout per read (default 360)
-- @return A thread object (from the `llthreads2` Lua package)
local function udp_server(port, n, timeout)
local threads = require "llthreads2.ex"
if CONSTANTS.TEST_COVERAGE_MODE == "true" then
timeout = CONSTANTS.TEST_COVERAGE_TIMEOUT
end
local thread = threads.new({
function(port, n, timeout)
local socket = require "socket"
local server = assert(socket.udp())
server:settimeout(timeout or 360)
server:setoption("reuseaddr", true)
server:setsockname("127.0.0.1", port)
local err
local data = {}
local handshake_done = false
local i = 0
while i < n do
local pkt, rport
pkt, err, rport = server:receivefrom()
if not pkt then
break
end
if pkt == "KONG_UDP_HELLO" then
if not handshake_done then
handshake_done = true
server:sendto("KONG_UDP_READY", "127.0.0.1", rport)
end
else
i = i + 1
data[i] = pkt
err = nil -- upon succes it would contain the remote ip address
end
end
server:close()
return (n > 1 and data or data[1]), err
end
}, port or CONSTANTS.MOCK_UPSTREAM_PORT, n or 1, timeout)
thread:start()
local socket = require "socket"
local handshake = socket.udp()
handshake:settimeout(0.01)
handshake:setsockname("127.0.0.1", 0)
while true do
handshake:sendto("KONG_UDP_HELLO", "127.0.0.1", port)
local data = handshake:receive()
if data == "KONG_UDP_READY" then
break
end
end
handshake:close()
return thread
end
local is_echo_server_ready, get_echo_server_received_data, echo_server_reset
do
local shell = require("spec.internal.shell")
local cmd = require("spec.internal.cmd")
-- Message id is maintained within echo server context and not
-- needed for echo server user.
-- This id is extracted from the number in nginx error.log at each
-- line of log. i.e.:
-- 2023/12/15 14:10:12 [info] 718291#0: *303 stream [lua] content_by_lua ...
-- in above case, the id is 303.
local msg_id = -1
local prefix_dir = "servroot"
--- Check if echo server is ready.
--
-- @function is_echo_server_ready
-- @return boolean
function is_echo_server_ready()
-- ensure server is ready.
local sock = ngx.socket.tcp()
sock:settimeout(0.1)
local retry = 0
local test_port = 8188
while true do
if sock:connect("localhost", test_port) then
sock:send("START\n")
local ok = sock:receive()
sock:close()
if ok == "START" then
return true
end
else
retry = retry + 1
if retry > 10 then
return false
end
end
end
end
--- Get the echo server's received data.
-- This function check the part of expected data with a timeout.
--
-- @function get_echo_server_received_data
-- @param expected part of the data expected.
-- @param timeout (optional) timeout in seconds, default is 0.5.
-- @return the data the echo server received. If timeouts, return "timeout".
function get_echo_server_received_data(expected, timeout)
if timeout == nil then
timeout = 0.5
end
local extract_cmd = "grep content_by_lua "..prefix_dir.."/logs/error.log | tail -1"
local _, _, log = assert(shell.exec(extract_cmd))
local pattern = "%*(%d+)%s.*received data: (.*)"
local cur_msg_id, data = string.match(log, pattern)
-- unit is second.
local t = 0.1
local time_acc = 0
-- retry it when data is not available. because sometime,
-- the error.log has not been flushed yet.
while string.find(data, expected) == nil or cur_msg_id == msg_id do
ngx.sleep(t)
time_acc = time_acc + t
if time_acc >= timeout then
return "timeout"
end
_, _, log = assert(shell.exec(extract_cmd))
cur_msg_id, data = string.match(log, pattern)
end
-- update the msg_id, it persists during a cycle from echo server
-- start to stop.
msg_id = cur_msg_id
return data
end
function echo_server_reset()
cmd.stop_kong(prefix_dir)
msg_id = -1
end
end
return {
tcp_server = tcp_server,
kill_tcp_server = kill_tcp_server,
http_mock = http_mock,
udp_server = udp_server,
is_echo_server_ready = is_echo_server_ready,
echo_server_reset = echo_server_reset,
get_echo_server_received_data = get_echo_server_received_data,
}