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

693 lines
22 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 cjson = require("cjson.safe")
local lfs = require("lfs")
local pl_file = require("pl.file")
local luassert = require("luassert.assert")
local https_server = require("spec.fixtures.https_server")
local CONSTANTS = require("spec.internal.constants")
local shell = require("spec.internal.shell")
local asserts = require("spec.internal.asserts") -- luacheck: ignore
local client = require("spec.internal.client")
local get_available_port
do
local USED_PORTS = {}
function get_available_port()
for _i = 1, 10 do
local port = math.random(10000, 30000)
if not USED_PORTS[port] then
USED_PORTS[port] = true
local ok = shell.run("netstat -lnt | grep \":" .. port .. "\" > /dev/null", nil, 0)
if not ok then
-- return code of 1 means `grep` did not found the listening port
return port
else
print("Port " .. port .. " is occupied, trying another one")
end
end
end
error("Could not find an available port after 10 tries")
end
end
--------------------
-- Custom assertions
--
-- @section assertions
require("spec.helpers.wait")
--- Waits until a specific condition is met.
-- The check function will repeatedly be called (with a fixed interval), until
-- the condition is met. Throws an error on timeout.
--
-- NOTE: this is a regular Lua function, not a Luassert assertion.
-- @function wait_until
-- @param f check function that should return `truthy` when the condition has
-- been met
-- @param timeout (optional) maximum time to wait after which an error is
-- thrown, defaults to 5.
-- @param step (optional) interval between checks, defaults to 0.05.
-- @return nothing. It returns when the condition is met, or throws an error
-- when it times out.
-- @usage
-- -- wait 10 seconds for a file "myfilename" to appear
-- helpers.wait_until(function() return file_exist("myfilename") end, 10)
local function wait_until(f, timeout, step)
if CONSTANTS.TEST_COVERAGE_MODE == "true" then
timeout = CONSTANTS.TEST_COVERAGE_TIMEOUT
end
luassert.wait_until({
condition = "truthy",
fn = f,
timeout = timeout,
step = step,
})
end
--- Waits until no Lua error occurred
-- The check function will repeatedly be called (with a fixed interval), until
-- there is no Lua error occurred
--
-- NOTE: this is a regular Lua function, not a Luassert assertion.
-- @function pwait_until
-- @param f check function
-- @param timeout (optional) maximum time to wait after which an error is
-- thrown, defaults to 5.
-- @param step (optional) interval between checks, defaults to 0.05.
-- @return nothing. It returns when the condition is met, or throws an error
-- when it times out.
local function pwait_until(f, timeout, step)
if CONSTANTS.TEST_COVERAGE_MODE == "true" then
timeout = CONSTANTS.TEST_COVERAGE_TIMEOUT
end
luassert.wait_until({
condition = "no_error",
fn = f,
timeout = timeout,
step = step,
})
end
--- Wait for some timers, throws an error on timeout.
--
-- NOTE: this is a regular Lua function, not a Luassert assertion.
-- @function wait_timer
-- @tparam string timer_name_pattern the call will apply to all timers matching this string
-- @tparam boolean plain if truthy, the `timer_name_pattern` will be matched plain, so without pattern matching
-- @tparam string mode one of: "all-finish", "all-running", "any-finish", "any-running", or "worker-wide-all-finish"
--
-- any-finish: At least one of the timers that were matched finished
--
-- all-finish: All timers that were matched finished
--
-- any-running: At least one of the timers that were matched is running
--
-- all-running: All timers that were matched are running
--
-- worker-wide-all-finish: All the timers in the worker that were matched finished
-- @tparam number timeout maximum time to wait (optional, default: 2)
-- @tparam number admin_client_timeout, to override the default timeout setting (optional)
-- @tparam number forced_admin_port to override the default port of admin API (optional)
-- @usage helpers.wait_timer("rate-limiting", true, "all-finish", 10)
local function wait_timer(timer_name_pattern, plain,
mode, timeout,
admin_client_timeout, forced_admin_port)
if not timeout then
timeout = 2
end
local _admin_client
local all_running_each_worker = nil
local all_finish_each_worker = nil
local any_running_each_worker = nil
local any_finish_each_worker = nil
wait_until(function ()
if _admin_client then
_admin_client:close()
end
_admin_client = client.admin_client(admin_client_timeout, forced_admin_port)
local res = assert(_admin_client:get("/timers"))
local body = luassert.res_status(200, res)
local json = assert(cjson.decode(body))
local worker_id = json.worker.id
local worker_count = json.worker.count
if not all_running_each_worker then
all_running_each_worker = {}
all_finish_each_worker = {}
any_running_each_worker = {}
any_finish_each_worker = {}
for i = 0, worker_count - 1 do
all_running_each_worker[i] = false
all_finish_each_worker[i] = false
any_running_each_worker[i] = false
any_finish_each_worker[i] = false
end
end
local is_matched = false
for timer_name, timer in pairs(json.stats.timers) do
if string.find(timer_name, timer_name_pattern, 1, plain) then
is_matched = true
all_finish_each_worker[worker_id] = false
if timer.is_running then
all_running_each_worker[worker_id] = true
any_running_each_worker[worker_id] = true
goto continue
end
all_running_each_worker[worker_id] = false
goto continue
end
::continue::
end
if not is_matched then
any_finish_each_worker[worker_id] = true
all_finish_each_worker[worker_id] = true
end
local all_running = false
local all_finish = false
local all_finish_worker_wide = true
local any_running = false
local any_finish = false
for _, v in pairs(all_running_each_worker) do
all_running = all_running or v
end
for _, v in pairs(all_finish_each_worker) do
all_finish = all_finish or v
all_finish_worker_wide = all_finish_worker_wide and v
end
for _, v in pairs(any_running_each_worker) do
any_running = any_running or v
end
for _, v in pairs(any_finish_each_worker) do
any_finish = any_finish or v
end
if mode == "all-running" then
return all_running
end
if mode == "all-finish" then
return all_finish
end
if mode == "worker-wide-all-finish" then
return all_finish_worker_wide
end
if mode == "any-finish" then
return any_finish
end
if mode == "any-running" then
return any_running
end
error("unexpected error")
end, timeout)
end
--- Waits for invalidation of a cached key by polling the mgt-api
-- and waiting for a 404 response. Throws an error on timeout.
--
-- NOTE: this is a regular Lua function, not a Luassert assertion.
-- @function wait_for_invalidation
-- @param key (string) the cache-key to check
-- @param timeout (optional) in seconds (for default see `wait_until`).
-- @return nothing. It returns when the key is invalidated, or throws an error
-- when it times out.
-- @usage
-- local cache_key = "abc123"
-- helpers.wait_for_invalidation(cache_key, 10)
local function wait_for_invalidation(key, timeout)
-- TODO: this code is duplicated all over the codebase,
-- search codebase for "/cache/" endpoint
local api_client = client.admin_client()
wait_until(function()
local res = api_client:get("/cache/" .. key)
res:read_body()
return res.status == 404
end, timeout)
end
--- Wait for all targets, upstreams, services, and routes update
--
-- NOTE: this function is not available for DBless-mode
-- @function wait_for_all_config_update
-- @tparam[opt] table opts a table contains params
-- @tparam[opt=30] number opts.timeout maximum seconds to wait, defatuls is 30
-- @tparam[opt] number opts.admin_client_timeout to override the default timeout setting
-- @tparam[opt] number opts.forced_admin_port to override the default Admin API port
-- @tparam[opt] bollean opts.stream_enabled to enable stream module
-- @tparam[opt] number opts.proxy_client_timeout to override the default timeout setting
-- @tparam[opt] number opts.forced_proxy_port to override the default proxy port
-- @tparam[opt] number opts.stream_port to set the stream port
-- @tparam[opt] string opts.stream_ip to set the stream ip
-- @tparam[opt=false] boolean opts.override_global_rate_limiting_plugin to override the global rate-limiting plugin in waiting
-- @tparam[opt=false] boolean opts.override_global_key_auth_plugin to override the global key-auth plugin in waiting
local function wait_for_all_config_update(opts)
opts = opts or {}
if CONSTANTS.TEST_COVERAGE_MODE == "true" then
opts.timeout = CONSTANTS.TEST_COVERAGE_TIMEOUT
end
local timeout = opts.timeout or 30
local admin_client_timeout = opts.admin_client_timeout
local forced_admin_port = opts.forced_admin_port
local proxy_client_timeout = opts.proxy_client_timeout
local forced_proxy_port = opts.forced_proxy_port
local stream_port = opts.stream_port
local stream_ip = opts.stream_ip
local stream_enabled = opts.stream_enabled or false
local override_rl = opts.override_global_rate_limiting_plugin or false
local override_auth = opts.override_global_key_auth_plugin or false
local headers = opts.override_default_headers or { ["Content-Type"] = "application/json" }
local disable_ipv6 = opts.disable_ipv6 or false
local function call_admin_api(method, path, body, expected_status, headers)
local client = client.admin_client(admin_client_timeout, forced_admin_port)
local res
if string.upper(method) == "POST" then
res = client:post(path, {
headers = headers,
body = body,
})
elseif string.upper(method) == "DELETE" then
res = client:delete(path, {
headers = headers
})
end
local ok, json_or_nil_or_err = pcall(function ()
assert(res.status == expected_status, "unexpected response code: " .. res.status)
if string.upper(method) == "DELETE" then
return
end
local json = cjson.decode((res:read_body()))
assert(json ~= nil, "unexpected response body")
return json
end)
client:close()
assert(ok, json_or_nil_or_err)
return json_or_nil_or_err
end
local upstream_id, target_id, service_id, route_id
local stream_upstream_id, stream_target_id, stream_service_id, stream_route_id
local consumer_id, rl_plugin_id, key_auth_plugin_id, credential_id
local upstream_name = "really.really.really.really.really.really.really.mocking.upstream.test"
local service_name = "really-really-really-really-really-really-really-mocking-service"
local stream_upstream_name = "stream-really.really.really.really.really.really.really.mocking.upstream.test"
local stream_service_name = "stream-really-really-really-really-really-really-really-mocking-service"
local route_path = "/really-really-really-really-really-really-really-mocking-route"
local key_header_name = "really-really-really-really-really-really-really-mocking-key"
local consumer_name = "really-really-really-really-really-really-really-mocking-consumer"
local test_credentials = "really-really-really-really-really-really-really-mocking-credentials"
local host = "localhost"
local port = get_available_port()
local server = https_server.new(port, host, "http", nil, 1, nil, disable_ipv6)
server:start()
-- create mocking upstream
local res = assert(call_admin_api("POST",
"/upstreams",
{ name = upstream_name },
201, headers))
upstream_id = res.id
-- create mocking target to mocking upstream
res = assert(call_admin_api("POST",
string.format("/upstreams/%s/targets", upstream_id),
{ target = host .. ":" .. port },
201, headers))
target_id = res.id
-- create mocking service to mocking upstream
res = assert(call_admin_api("POST",
"/services",
{ name = service_name, url = "http://" .. upstream_name .. "/always_200" },
201, headers))
service_id = res.id
-- create mocking route to mocking service
res = assert(call_admin_api("POST",
string.format("/services/%s/routes", service_id),
{ paths = { route_path }, strip_path = true, path_handling = "v0",},
201, headers))
route_id = res.id
if override_rl then
-- create rate-limiting plugin to mocking mocking service
res = assert(call_admin_api("POST",
string.format("/services/%s/plugins", service_id),
{ name = "rate-limiting", config = { minute = 999999, policy = "local" } },
201, headers))
rl_plugin_id = res.id
end
if override_auth then
-- create key-auth plugin to mocking mocking service
res = assert(call_admin_api("POST",
string.format("/services/%s/plugins", service_id),
{ name = "key-auth", config = { key_names = { key_header_name } } },
201, headers))
key_auth_plugin_id = res.id
-- create consumer
res = assert(call_admin_api("POST",
"/consumers",
{ username = consumer_name },
201, headers))
consumer_id = res.id
-- create credential to key-auth plugin
res = assert(call_admin_api("POST",
string.format("/consumers/%s/key-auth", consumer_id),
{ key = test_credentials },
201, headers))
credential_id = res.id
end
if stream_enabled then
-- create mocking upstream
local res = assert(call_admin_api("POST",
"/upstreams",
{ name = stream_upstream_name },
201, headers))
stream_upstream_id = res.id
-- create mocking target to mocking upstream
res = assert(call_admin_api("POST",
string.format("/upstreams/%s/targets", stream_upstream_id),
{ target = host .. ":" .. port },
201, headers))
stream_target_id = res.id
-- create mocking service to mocking upstream
res = assert(call_admin_api("POST",
"/services",
{ name = stream_service_name, url = "tcp://" .. stream_upstream_name },
201, headers))
stream_service_id = res.id
-- create mocking route to mocking service
res = assert(call_admin_api("POST",
string.format("/services/%s/routes", stream_service_id),
{ destinations = { { port = stream_port }, }, protocols = { "tcp" },},
201, headers))
stream_route_id = res.id
end
local ok, err = pcall(function ()
-- wait for mocking route ready
pwait_until(function ()
local proxy = client.proxy_client(proxy_client_timeout, forced_proxy_port)
if override_auth then
res = proxy:get(route_path, { headers = { [key_header_name] = test_credentials } })
else
res = proxy:get(route_path)
end
local ok, err = pcall(assert, res.status == 200)
proxy:close()
assert(ok, err)
end, timeout / 2)
if stream_enabled then
pwait_until(function ()
local proxy = client.proxy_client(proxy_client_timeout, stream_port, stream_ip)
res = proxy:get("/always_200")
local ok, err = pcall(assert, res.status == 200)
proxy:close()
assert(ok, err)
end, timeout)
end
end)
if not ok then
server:shutdown()
error(err)
end
-- delete mocking configurations
if override_auth then
call_admin_api("DELETE", string.format("/consumers/%s/key-auth/%s", consumer_id, credential_id), nil, 204, headers)
call_admin_api("DELETE", string.format("/consumers/%s", consumer_id), nil, 204, headers)
call_admin_api("DELETE", "/plugins/" .. key_auth_plugin_id, nil, 204, headers)
end
if override_rl then
call_admin_api("DELETE", "/plugins/" .. rl_plugin_id, nil, 204, headers)
end
call_admin_api("DELETE", "/routes/" .. route_id, nil, 204, headers)
call_admin_api("DELETE", "/services/" .. service_id, nil, 204, headers)
call_admin_api("DELETE", string.format("/upstreams/%s/targets/%s", upstream_id, target_id), nil, 204, headers)
call_admin_api("DELETE", "/upstreams/" .. upstream_id, nil, 204, headers)
if stream_enabled then
call_admin_api("DELETE", "/routes/" .. stream_route_id, nil, 204, headers)
call_admin_api("DELETE", "/services/" .. stream_service_id, nil, 204, headers)
call_admin_api("DELETE", string.format("/upstreams/%s/targets/%s", stream_upstream_id, stream_target_id), nil, 204, headers)
call_admin_api("DELETE", "/upstreams/" .. stream_upstream_id, nil, 204, headers)
end
ok, err = pcall(function ()
-- wait for mocking configurations to be deleted
pwait_until(function ()
local proxy = client.proxy_client(proxy_client_timeout, forced_proxy_port)
res = proxy:get(route_path)
local ok, err = pcall(assert, res.status == 404)
proxy:close()
assert(ok, err)
end, timeout / 2)
end)
server:shutdown()
if not ok then
error(err)
end
end
--- Waits for a file to meet a certain condition
-- The check function will repeatedly be called (with a fixed interval), until
-- there is no Lua error occurred
--
-- NOTE: this is a regular Lua function, not a Luassert assertion.
-- @function wait_for_file
-- @tparam string mode one of:
--
-- "file", "directory", "link", "socket", "named pipe", "char device", "block device", "other"
--
-- @tparam string path the file path
-- @tparam[opt=10] number timeout maximum seconds to wait
local function wait_for_file(mode, path, timeout)
pwait_until(function()
local result, err = lfs.attributes(path, "mode")
local msg = string.format("failed to wait for the mode (%s) of '%s': %s",
mode, path, tostring(err))
assert(result == mode, msg)
end, timeout or 10)
end
local wait_for_file_contents
do
--- Wait until a file exists and is non-empty.
--
-- If, after the timeout is reached, the file does not exist, is not
-- readable, or is empty, an assertion error will be raised.
--
-- @function wait_for_file_contents
-- @param fname the filename to wait for
-- @param timeout (optional) maximum time to wait after which an error is
-- thrown, defaults to 10.
-- @return contents the file contents, as a string
function wait_for_file_contents(fname, timeout)
assert(type(fname) == "string",
"filename must be a string")
timeout = timeout or 10
assert(type(timeout) == "number" and timeout >= 0,
"timeout must be nil or a number >= 0")
local data = pl_file.read(fname)
if data and #data > 0 then
return data
end
pcall(wait_until, function()
data = pl_file.read(fname)
return data and #data > 0
end, timeout)
assert(data, "file (" .. fname .. ") does not exist or is not readable"
.. " after " .. tostring(timeout) .. " seconds")
assert(#data > 0, "file (" .. fname .. ") exists but is empty after " ..
tostring(timeout) .. " seconds")
return data
end
end
local function wait_until_no_common_workers(workers, expected_total)
wait_until(function()
local pok, admin_client = pcall(client.admin_client)
if not pok then
return false
end
local res = assert(admin_client:send {
method = "GET",
path = "/",
})
luassert.res_status(200, res)
local json = cjson.decode(luassert.res_status(200, res))
admin_client:close()
local new_workers = json.pids.workers
local total = 0
local common = 0
if new_workers then
for _, v in ipairs(new_workers) do
total = total + 1
for _, v_old in ipairs(workers) do
if v == v_old then
common = common + 1
break
end
end
end
end
return common == 0 and total == (expected_total or total)
end, 30)
end
local function get_kong_workers(expected_total)
local workers
wait_until(function()
local pok, admin_client = pcall(client.admin_client)
if not pok then
return false
end
local res = admin_client:send {
method = "GET",
path = "/",
}
if not res or res.status ~= 200 then
return false
end
local body = luassert.res_status(200, res)
local json = cjson.decode(body)
admin_client:close()
workers = {}
for _, item in ipairs(json.pids.workers) do
if item ~= ngx.null then
table.insert(workers, item)
end
end
if expected_total and #workers ~= expected_total then
return nil, ("expected %s worker pids, got %s"):format(expected_total,
#workers)
elseif #workers == 0 then
return nil, "GET / returned no worker pids"
end
return true
end, 10)
return workers
end
--- Reload Kong and wait all workers are restarted.
local function reload_kong(...)
local workers = get_kong_workers()
local ok, err = shell.kong_exec(...)
if ok then
wait_until_no_common_workers(workers, 1)
end
return ok, err
end
return {
get_available_port = get_available_port,
wait_until = wait_until,
pwait_until = pwait_until,
wait_timer = wait_timer,
wait_for_invalidation = wait_for_invalidation,
wait_for_all_config_update = wait_for_all_config_update,
wait_for_file = wait_for_file,
wait_for_file_contents = wait_for_file_contents,
wait_until_no_common_workers = wait_until_no_common_workers,
get_kong_workers = get_kong_workers,
reload_kong = reload_kong,
}