kong/spec/helpers.lua (2,117 lines of code) (raw):

------------------------------------------------------------------ -- 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 BIN_PATH = "bin/kong" local TEST_CONF_PATH = os.getenv("KONG_SPEC_TEST_CONF_PATH") or "spec/kong_tests.conf" local CUSTOM_PLUGIN_PATH = "./spec/fixtures/custom_plugins/?.lua" local CUSTOM_VAULT_PATH = "./spec/fixtures/custom_vaults/?.lua;./spec/fixtures/custom_vaults/?/init.lua" local DNS_MOCK_LUA_PATH = "./spec/fixtures/mocks/lua-resty-dns/?.lua" local GO_PLUGIN_PATH = "./spec/fixtures/go" local GRPC_TARGET_SRC_PATH = "./spec/fixtures/grpc/target/" local MOCK_UPSTREAM_PROTOCOL = "http" local MOCK_UPSTREAM_SSL_PROTOCOL = "https" local MOCK_UPSTREAM_HOST = "127.0.0.1" local MOCK_UPSTREAM_HOSTNAME = "localhost" local MOCK_UPSTREAM_PORT = 15555 local MOCK_UPSTREAM_SSL_PORT = 15556 local MOCK_UPSTREAM_STREAM_PORT = 15557 local MOCK_UPSTREAM_STREAM_SSL_PORT = 15558 local GRPCBIN_HOST = os.getenv("KONG_SPEC_TEST_GRPCBIN_HOST") or "localhost" local GRPCBIN_PORT = tonumber(os.getenv("KONG_SPEC_TEST_GRPCBIN_PORT")) or 9000 local GRPCBIN_SSL_PORT = tonumber(os.getenv("KONG_SPEC_TEST_GRPCBIN_SSL_PORT")) or 9001 local MOCK_GRPC_UPSTREAM_PROTO_PATH = "./spec/fixtures/grpc/hello.proto" local ZIPKIN_HOST = os.getenv("KONG_SPEC_TEST_ZIPKIN_HOST") or "localhost" local ZIPKIN_PORT = tonumber(os.getenv("KONG_SPEC_TEST_ZIPKIN_PORT")) or 9411 local OTELCOL_HOST = os.getenv("KONG_SPEC_TEST_OTELCOL_HOST") or "localhost" local OTELCOL_HTTP_PORT = tonumber(os.getenv("KONG_SPEC_TEST_OTELCOL_HTTP_PORT")) or 4318 local OTELCOL_ZPAGES_PORT = tonumber(os.getenv("KONG_SPEC_TEST_OTELCOL_ZPAGES_PORT")) or 55679 local OTELCOL_FILE_EXPORTER_PATH = os.getenv("KONG_SPEC_TEST_OTELCOL_FILE_EXPORTER_PATH") or "./tmp/otel/file_exporter.json" local REDIS_HOST = os.getenv("KONG_SPEC_TEST_REDIS_HOST") or "localhost" local REDIS_PORT = tonumber(os.getenv("KONG_SPEC_TEST_REDIS_PORT") or 6379) local REDIS_SSL_PORT = tonumber(os.getenv("KONG_SPEC_TEST_REDIS_SSL_PORT") or 6380) local REDIS_SSL_SNI = os.getenv("KONG_SPEC_TEST_REDIS_SSL_SNI") or "test-redis.example.com" local BLACKHOLE_HOST = "10.255.255.255" local KONG_VERSION = require("kong.meta")._VERSION local PLUGINS_LIST local consumers_schema_def = require "kong.db.schema.entities.consumers" local services_schema_def = require "kong.db.schema.entities.services" local plugins_schema_def = require "kong.db.schema.entities.plugins" local routes_schema_def = require "kong.db.schema.entities.routes" local prefix_handler = require "kong.cmd.utils.prefix_handler" local dc_blueprints = require "spec.fixtures.dc_blueprints" local conf_loader = require "kong.conf_loader" local kong_global = require "kong.global" local Blueprints = require "spec.fixtures.blueprints" local pl_stringx = require "pl.stringx" local constants = require "kong.constants" local pl_tablex = require "pl.tablex" local pl_utils = require "pl.utils" local pl_path = require "pl.path" local pl_file = require "pl.file" local version = require "version" local pl_dir = require "pl.dir" local pl_Set = require "pl.Set" local Schema = require "kong.db.schema" local Entity = require "kong.db.schema.entity" local cjson = require "cjson.safe" local utils = require "kong.tools.utils" local http = require "resty.http" local nginx_signals = require "kong.cmd.utils.nginx_signals" local log = require "kong.cmd.utils.log" local DB = require "kong.db" local ffi = require "ffi" local ssl = require "ngx.ssl" local ws_client = require "resty.websocket.client" local table_clone = require "table.clone" local https_server = require "spec.fixtures.https_server" local stress_generator = require "spec.fixtures.stress_generator" local resty_signal = require "resty.signal" ffi.cdef [[ int setenv(const char *name, const char *value, int overwrite); int unsetenv(const char *name); ]] local kong_exec -- forward declaration log.set_lvl(log.levels.quiet) -- disable stdout logs in tests -- Add to package path so dao helpers can insert custom plugins -- (while running from the busted environment) do local paths = {} table.insert(paths, os.getenv("KONG_LUA_PACKAGE_PATH")) table.insert(paths, CUSTOM_PLUGIN_PATH) table.insert(paths, CUSTOM_VAULT_PATH) table.insert(paths, package.path) package.path = table.concat(paths, ";") end --- Returns the OpenResty version. -- Extract the current OpenResty version in use and returns -- a numerical representation of it. -- Ex: `1.11.2.2` -> `11122` -- @function openresty_ver_num local function openresty_ver_num() local nginx_bin = assert(nginx_signals.find_nginx_bin()) local _, _, _, stderr = pl_utils.executeex(string.format("%s -V", nginx_bin)) local a, b, c, d = string.match(stderr or "", "openresty/(%d+)%.(%d+)%.(%d+)%.(%d+)") if not a then error("could not execute 'nginx -V': " .. stderr) end return tonumber(a .. b .. c .. d) end --- Unindent a multi-line string for proper indenting in -- square brackets. -- @function unindent -- @usage -- local u = helpers.unindent -- -- u[[ -- hello world -- foo bar -- ]] -- -- -- will return: "hello world\nfoo bar" local function unindent(str, concat_newlines, spaced_newlines) str = string.match(str, "(.-%S*)%s*$") if not str then return "" end local level = math.huge local prefix = "" local len str = str:match("^%s") and "\n" .. str or str for pref in str:gmatch("\n(%s+)") do len = #prefix if len < level then level = len prefix = pref end end local repl = concat_newlines and "" or "\n" repl = spaced_newlines and " " or repl return (str:gsub("^\n%s*", ""):gsub("\n" .. prefix, repl):gsub("\n$", ""):gsub("\\r", "\r")) end --- Set an environment variable -- @function setenv -- @param env (string) name of the environment variable -- @param value the value to set -- @return true on success, false otherwise local function setenv(env, value) return ffi.C.setenv(env, value, 1) == 0 end --- Unset an environment variable -- @function unsetenv -- @param env (string) name of the environment variable -- @return true on success, false otherwise local function unsetenv(env) return ffi.C.unsetenv(env) == 0 end --- Write a yaml file. -- @function make_yaml_file -- @param content (string) the yaml string to write to the file, if omitted the -- current database contents will be written using `kong config db_export`. -- @param filename (optional) if not provided, a temp name will be created -- @return filename of the file written local function make_yaml_file(content, filename) local filename = filename or pl_path.tmpname() .. ".yml" if content then local fd = assert(io.open(filename, "w")) assert(fd:write(unindent(content))) assert(fd:write("\n")) -- ensure last line ends in newline assert(fd:close()) else assert(kong_exec("config db_export --conf "..TEST_CONF_PATH.." "..filename)) end return filename end --------------- -- Conf and DAO --------------- local conf = assert(conf_loader(TEST_CONF_PATH)) _G.kong = kong_global.new() kong_global.init_pdk(_G.kong, conf) ngx.ctx.KONG_PHASE = kong_global.phases.access _G.kong.core_cache = { get = function(self, key, opts, func, ...) if key == constants.CLUSTER_ID_PARAM_KEY then return "123e4567-e89b-12d3-a456-426655440000" end return func(...) end } local db = assert(DB.new(conf)) assert(db:init_connector()) db.plugins:load_plugin_schemas(conf.loaded_plugins) db.vaults:load_vault_schemas(conf.loaded_vaults) local blueprints = assert(Blueprints.new(db)) local dcbp local config_yml --- Iterator over DB strategies. -- @function each_strategy -- @param strategies (optional string array) explicit list of strategies to use, -- defaults to `{ "postgres", "cassandra" }`. -- @see all_strategies -- @usage -- -- repeat all tests for each strategy -- for _, strategy_name in helpers.each_strategy() do -- describe("my test set [#" .. strategy .. "]", function() -- -- -- add your tests here -- -- end) -- end local function each_strategy() -- luacheck: ignore -- required to trick ldoc into processing for docs end --- Iterator over all strategies, the DB ones and the DB-less one. -- To test with DB-less, check the example. -- @function all_strategies -- @param strategies (optional string array) explicit list of strategies to use, -- defaults to `{ "postgres", "cassandra", "off" }`. -- @see each_strategy -- @see make_yaml_file -- @usage -- -- example of using DB-less testing -- -- -- use "all_strategies" to iterate over; "postgres", "cassandra", "off" -- for _, strategy in helpers.all_strategies() do -- describe(PLUGIN_NAME .. ": (access) [#" .. strategy .. "]", function() -- -- lazy_setup(function() -- -- -- when calling "get_db_utils" with "strategy=off", we still use -- -- "postgres" so we can write the test setup to the database. -- local bp = helpers.get_db_utils( -- strategy == "off" and "postgres" or strategy, -- nil, { PLUGIN_NAME }) -- -- -- Inject a test route, when "strategy=off" it will still be written -- -- to Postgres. -- local route1 = bp.routes:insert({ -- hosts = { "test1.com" }, -- }) -- -- -- start kong -- assert(helpers.start_kong({ -- -- set the strategy -- database = strategy, -- nginx_conf = "spec/fixtures/custom_nginx.template", -- plugins = "bundled," .. PLUGIN_NAME, -- -- -- The call to "make_yaml_file" will write the contents of -- -- the database to a temporary file, which filename is returned. -- -- But only when "strategy=off". -- declarative_config = strategy == "off" and helpers.make_yaml_file() or nil, -- -- -- the below lines can be omitted, but are just to prove that the test -- -- really runs DB-less despite that Postgres was used as intermediary -- -- storage. -- pg_host = strategy == "off" and "unknownhost.konghq.com" or nil, -- cassandra_contact_points = strategy == "off" and "unknownhost.konghq.com" or nil, -- })) -- end) -- -- ... rest of your test file local function all_strategies() -- luacheck: ignore -- required to trick ldoc into processing for docs end do local def_db_strategies = {"postgres", "cassandra"} local def_all_strategies = {"postgres", "cassandra", "off"} local env_var = os.getenv("KONG_DATABASE") if env_var then def_db_strategies = { env_var } def_all_strategies = { env_var } end local db_available_strategies = pl_Set(def_db_strategies) local all_available_strategies = pl_Set(def_all_strategies) local function iter(strategies, i) i = i + 1 local strategy = strategies[i] if strategy then return i, strategy end end each_strategy = function(strategies) if not strategies then return iter, def_db_strategies, 0 end for i = #strategies, 1, -1 do if not db_available_strategies[strategies[i]] then table.remove(strategies, i) end end return iter, strategies, 0 end all_strategies = function(strategies) if not strategies then return iter, def_all_strategies, 0 end for i = #strategies, 1, -1 do if not all_available_strategies[strategies[i]] then table.remove(strategies, i) end end return iter, strategies, 0 end end local function truncate_tables(db, tables) if not tables then return end for _, t in ipairs(tables) do if db[t] and db[t].schema then db[t]:truncate() end end end local function bootstrap_database(db) local schema_state = assert(db:schema_state()) if schema_state.needs_bootstrap then assert(db:schema_bootstrap()) end if schema_state.new_migrations then assert(db:run_migrations(schema_state.new_migrations, { run_up = true, run_teardown = true, })) end end --- Gets the database utility helpers and prepares the database for a testrun. -- This will a.o. bootstrap the datastore and truncate the existing data that -- migth be in it. The BluePrint returned can be used to create test entities -- in the database. -- @function get_db_utils -- @param strategy (optional) the database strategy to use, will default to the -- strategy in the test configuration. -- @param tables (optional) tables to truncate, this can be used to accelarate -- tests if only a few tables are used. By default all tables will be truncated. -- @param plugins (optional) array of plugins to mark as loaded. Since kong will -- load all the bundled plugins by default, this is useful mostly for marking -- custom plugins as loaded. -- @param vaults (optional) vault configuration to use. -- @param skip_migrations (optional) if true, migrations will not be run. -- @return BluePrint, DB -- @usage -- local PLUGIN_NAME = "my_fancy_plugin" -- local bp = helpers.get_db_utils("postgres", nil, { PLUGIN_NAME }) -- -- -- Inject a test route. No need to create a service, there is a default -- -- service which will echo the request. -- local route1 = bp.routes:insert({ -- hosts = { "test1.com" }, -- }) -- -- add the plugin to test to the route we created -- bp.plugins:insert { -- name = PLUGIN_NAME, -- route = { id = route1.id }, -- config = {}, -- } local function get_db_utils(strategy, tables, plugins, vaults, skip_migrations) strategy = strategy or conf.database if tables ~= nil and type(tables) ~= "table" then error("arg #2 must be a list of tables to truncate", 2) end if plugins ~= nil and type(plugins) ~= "table" then error("arg #3 must be a list of plugins to enable", 2) end if plugins then for _, plugin in ipairs(plugins) do conf.loaded_plugins[plugin] = true end end if vaults ~= nil and type(vaults) ~= "table" then error("arg #4 must be a list of vaults to enable", 2) end if vaults then for _, vault in ipairs(vaults) do conf.loaded_vaults[vault] = true end end -- Clean workspaces from the context - otherwise, migrations will fail, -- as some of them have dao calls -- If `no_truncate` is falsey, `dao:truncate` and `db:truncate` are called, -- and these set the workspace back again to the new `default` workspace ngx.ctx.workspace = nil -- DAO (DB module) local db = assert(DB.new(conf, strategy)) assert(db:init_connector()) if not skip_migrations then bootstrap_database(db) end do local database = conf.database conf.database = strategy conf.database = database end db:truncate("plugins") assert(db.plugins:load_plugin_schemas(conf.loaded_plugins)) assert(db.vaults:load_vault_schemas(conf.loaded_vaults)) -- cleanup the tags table, since it will be hacky and -- not necessary to implement "truncate trigger" in Cassandra db:truncate("tags") _G.kong.db = db -- cleanup tables if not tables then assert(db:truncate()) else tables[#tables + 1] = "workspaces" truncate_tables(db, tables) end -- blueprints local bp if strategy ~= "off" then bp = assert(Blueprints.new(db)) dcbp = nil else bp = assert(dc_blueprints.new(db)) dcbp = bp end if plugins then for _, plugin in ipairs(plugins) do conf.loaded_plugins[plugin] = false end end if vaults then for _, vault in ipairs(vaults) do conf.loaded_vaults[vault] = false end end if strategy ~= "off" then local workspaces = require "kong.workspaces" workspaces.upsert_default(db) end -- calculation can only happen here because this function -- initializes the kong.db instance PLUGINS_LIST = assert(kong.db.plugins:get_handlers()) table.sort(PLUGINS_LIST, function(a, b) return a.name:lower() < b.name:lower() end) PLUGINS_LIST = pl_tablex.map(function(p) return { name = p.name, version = p.handler.VERSION, } end, PLUGINS_LIST) return bp, db end --- Gets the ml_cache instance. -- @function get_cache -- @param db the database object -- @return ml_cache instance local function get_cache(db) local worker_events = assert(kong_global.init_worker_events()) local cluster_events = assert(kong_global.init_cluster_events(conf, db)) local cache = assert(kong_global.init_cache(conf, cluster_events, worker_events )) return cache end ----------------- -- Custom helpers ----------------- local resty_http_proxy_mt = setmetatable({}, { __index = http }) resty_http_proxy_mt.__index = resty_http_proxy_mt local pack = function(...) return { n = select("#", ...), ... } end local unpack = function(t) return unpack(t, 1, t.n) end --- Prints all returned parameters. -- Simple debugging aid, it will pass all received parameters, hence will not -- influence the flow of the code. See also `fail`. -- @function intercept -- @see fail -- @usage -- modify -- local a,b = some_func(c,d) -- -- into -- local a,b = intercept(some_func(c,d)) local function intercept(...) local args = pack(...) print(require("pl.pretty").write(args)) return unpack(args) end -- Prepopulate Schema's cache Schema.new(consumers_schema_def) Schema.new(services_schema_def) Schema.new(routes_schema_def) local plugins_schema = assert(Entity.new(plugins_schema_def)) --- Validate a plugin configuration against a plugin schema. -- @function validate_plugin_config_schema -- @param config The configuration to validate. This is not the full schema, -- only the `config` sub-object needs to be passed. -- @param schema_def The schema definition -- @return the validated schema, or nil+error local function validate_plugin_config_schema(config, schema_def) assert(plugins_schema:new_subschema(schema_def.name, schema_def)) local entity = { id = utils.uuid(), name = schema_def.name, config = config } local entity_to_insert, err = plugins_schema:process_auto_fields(entity, "insert") if err then return nil, err end local _, err = plugins_schema:validate_insert(entity_to_insert) if err then return nil, err end return entity_to_insert end -- Case insensitive lookup function, returns the value and the original key. Or -- if not found nil and the search key -- @usage -- sample usage -- local test = { SoMeKeY = 10 } -- print(lookup(test, "somekey")) --> 10, "SoMeKeY" -- print(lookup(test, "NotFound")) --> nil, "NotFound" local function lookup(t, k) local ok = k if type(k) ~= "string" then return t[k], k else k = k:lower() end for key, value in pairs(t) do if tostring(key):lower() == k then return value, key end end return nil, ok end --- http_client. -- An http-client class to perform requests. -- -- * Based on [lua-resty-http](https://github.com/pintsized/lua-resty-http) but -- with some modifications -- -- * Additional convenience methods will be injected for the following methods; -- "get", "post", "put", "patch", "delete". Each of these methods comes with a -- built-in assert. The signature of the functions is `client:get(path, opts)`. -- -- * Body will be formatted according to the "Content-Type" header, see `http_client:send`. -- -- * Query parameters will be added, see `http_client:send`. -- -- @section http_client -- @usage -- -- example usage of the client -- local client = helpers.proxy_client() -- -- no need to check for `nil+err` since it is already wrapped in an assert -- -- local opts = { -- headers = { -- ["My-Header"] = "my header value" -- } -- } -- local result = client:get("/services/foo", opts) -- -- the 'get' is wrapped in an assert, so again no need to check for `nil+err` --- Send a http request. -- Based on [lua-resty-http](https://github.com/pintsized/lua-resty-http). -- -- * If `opts.body` is a table and "Content-Type" header contains -- `application/json`, `www-form-urlencoded`, or `multipart/form-data`, then it -- will automatically encode the body according to the content type. -- -- * If `opts.query` is a table, a query string will be constructed from it and -- appended to the request path (assuming none is already present). -- -- * instead of this generic function there are also shortcut functions available -- for every method, eg. `client:get`, `client:post`, etc. See `http_client`. -- -- @function http_client:send -- @param opts table with options. See [lua-resty-http](https://github.com/pintsized/lua-resty-http) function resty_http_proxy_mt:send(opts) local cjson = require "cjson" local utils = require "kong.tools.utils" opts = opts or {} -- build body local headers = opts.headers or {} local content_type, content_type_name = lookup(headers, "Content-Type") content_type = content_type or "" local t_body_table = type(opts.body) == "table" if string.find(content_type, "application/json") and t_body_table then opts.body = cjson.encode(opts.body) elseif string.find(content_type, "www-form-urlencoded", nil, true) and t_body_table then opts.body = utils.encode_args(opts.body, true, opts.no_array_indexes) elseif string.find(content_type, "multipart/form-data", nil, true) and t_body_table then local form = opts.body local boundary = "8fd84e9444e3946c" local body = "" for k, v in pairs(form) do body = body .. "--" .. boundary .. "\r\nContent-Disposition: form-data; name=\"" .. k .. "\"\r\n\r\n" .. tostring(v) .. "\r\n" end if body ~= "" then body = body .. "--" .. boundary .. "--\r\n" end local clength = lookup(headers, "content-length") if not clength and not opts.dont_add_content_length then headers["content-length"] = #body end if not content_type:find("boundary=") then headers[content_type_name] = content_type .. "; boundary=" .. boundary end opts.body = body end -- build querystring (assumes none is currently in 'opts.path') if type(opts.query) == "table" then local qs = utils.encode_args(opts.query) opts.path = opts.path .. "?" .. qs opts.query = nil end local res, err = self:request(opts) if res then -- wrap the read_body() so it caches the result and can be called multiple -- times local reader = res.read_body res.read_body = function(self) if not self._cached_body and not self._cached_error then self._cached_body, self._cached_error = reader(self) end return self._cached_body, self._cached_error end end return res, err end -- Implements http_client:get("path", [options]), as well as post, put, etc. -- These methods are equivalent to calling http_client:send, but are shorter -- They also come with a built-in assert for _, method_name in ipairs({"get", "post", "put", "patch", "delete"}) do resty_http_proxy_mt[method_name] = function(self, path, options) local full_options = kong.table.merge({ method = method_name:upper(), path = path}, options) return assert(self:send(full_options)) end end --- Creates a http client from options. -- Instead of using this client, you'll probably want to use the pre-configured -- clients available as `proxy_client`, `admin_client`, etc. because these come -- pre-configured and connected to the underlying Kong test instance. -- -- @function http_client_opts -- @param options connection and other options -- @return http client -- @see http_client:send -- @see proxy_client -- @see proxy_ssl_client -- @see admin_client -- @see admin_ssl_client local function http_client_opts(options) if not options.scheme then options = utils.deep_copy(options) options.scheme = "http" if options.port == 443 then options.scheme = "https" else options.scheme = "http" end end local self = setmetatable(assert(http.new()), resty_http_proxy_mt) local _, err = self:connect(options) if err then error("Could not connect to " .. (options.host or "unknown") .. ":" .. (options.port or "unknown") .. ": " .. err) end if options.connect_timeout and options.send_timeout and options.read_timeout then self:set_timeouts(options.connect_timeout, options.send_timeout, options.read_timeout) else self:set_timeout(options.timeout or 10000) end return self end --- Creates a http client. -- Instead of using this client, you'll probably want to use the pre-configured -- clients available as `proxy_client`, `admin_client`, etc. because these come -- pre-configured and connected to the underlying Kong test instance. -- -- @function http_client -- @param host hostname to connect to -- @param port port to connect to -- @param timeout in seconds -- @return http client -- @see http_client:send -- @see proxy_client -- @see proxy_ssl_client -- @see admin_client -- @see admin_ssl_client local function http_client(host, port, timeout) if type(host) == "table" then return http_client_opts(host) end return http_client_opts({ host = host, port = port, timeout = timeout, }) end --- Returns the proxy port. -- @function get_proxy_port -- @param ssl (boolean) if `true` returns the ssl port -- @param http2 (boolean) if `true` returns the http2 port local function get_proxy_port(ssl, http2) if ssl == nil then ssl = false end for _, entry in ipairs(conf.proxy_listeners) do if entry.ssl == ssl and (http2 == nil or entry.http2 == http2) then return entry.port end end error("No proxy port found for ssl=" .. tostring(ssl), 2) end --- Returns the proxy ip. -- @function get_proxy_ip -- @param ssl (boolean) if `true` returns the ssl ip address -- @param http2 (boolean) if `true` returns the http2 ip address local function get_proxy_ip(ssl, http2) if ssl == nil then ssl = false end for _, entry in ipairs(conf.proxy_listeners) do if entry.ssl == ssl and (http2 == nil or entry.http2 == http2) then return entry.ip end end error("No proxy ip found for ssl=" .. tostring(ssl), 2) end --- returns a pre-configured `http_client` for the Kong proxy port. -- @function proxy_client -- @param timeout (optional, number) the timeout to use -- @param forced_port (optional, number) if provided will override the port in -- the Kong configuration with this port local function proxy_client(timeout, forced_port) local proxy_ip = get_proxy_ip(false) local proxy_port = get_proxy_port(false) assert(proxy_ip, "No http-proxy found in the configuration") return http_client_opts({ scheme = "http", host = proxy_ip, port = forced_port or proxy_port, timeout = timeout or 60000, }) end --- returns a pre-configured `http_client` for the Kong SSL proxy port. -- @function proxy_ssl_client -- @param timeout (optional, number) the timeout to use -- @param sni (optional, string) the sni to use local function proxy_ssl_client(timeout, sni) local proxy_ip = get_proxy_ip(true, true) local proxy_port = get_proxy_port(true, true) assert(proxy_ip, "No https-proxy found in the configuration") local client = http_client_opts({ scheme = "https", host = proxy_ip, port = proxy_port, timeout = timeout or 60000, ssl_verify = false, ssl_server_name = sni, }) return client end --- returns a pre-configured `http_client` for the Kong admin port. -- @function admin_client -- @param timeout (optional, number) the timeout to use -- @param forced_port (optional, number) if provided will override the port in -- the Kong configuration with this port local function admin_client(timeout, forced_port) local admin_ip, admin_port for _, entry in ipairs(conf.admin_listeners) do if entry.ssl == false then admin_ip = entry.ip admin_port = entry.port end end assert(admin_ip, "No http-admin found in the configuration") return http_client_opts({ scheme = "http", host = admin_ip, port = forced_port or admin_port, timeout = timeout or 60000 }) end --- returns a pre-configured `http_client` for the Kong admin SSL port. -- @function admin_ssl_client -- @param timeout (optional, number) the timeout to use local function admin_ssl_client(timeout) local admin_ip, admin_port for _, entry in ipairs(conf.proxy_listeners) do if entry.ssl == true then admin_ip = entry.ip admin_port = entry.port end end assert(admin_ip, "No https-admin found in the configuration") local client = http_client_opts({ scheme = "https", host = admin_ip, port = admin_port, timeout = timeout or 60000, }) return client end ---------------- -- HTTP2 and GRPC clients -- @section Shell-helpers -- Generate grpcurl flags from a table of `flag-value`. If `value` is not a -- string, value is ignored and `flag` is passed as is. local function gen_grpcurl_opts(opts_t) local opts_l = {} for opt, val in pairs(opts_t) do if val ~= false then opts_l[#opts_l + 1] = opt .. " " .. (type(val) == "string" and val or "") end end return table.concat(opts_l, " ") end --- Creates an HTTP/2 client, based on the lua-http library. -- @function http2_client -- @param host hostname to connect to -- @param port port to connect to -- @param tls boolean indicating whether to establish a tls session -- @return http2 client local function http2_client(host, port, tls) local host = assert(host) local port = assert(port) tls = tls or false -- if Kong/lua-pack is loaded, unload it first -- so lua-http can use implementation from compat53.string package.loaded.string.unpack = nil package.loaded.string.pack = nil local request = require "http.request" local req = request.new_from_uri({ scheme = tls and "https" or "http", host = host, port = port, }) req.version = 2 req.tls = tls if tls then local http_tls = require "http.tls" local openssl_ctx = require "openssl.ssl.context" local n_ctx = http_tls.new_client_context() n_ctx:setVerify(openssl_ctx.VERIFY_NONE) req.ctx = n_ctx end local meta = getmetatable(req) or {} meta.__call = function(req, opts) local headers = opts and opts.headers local timeout = opts and opts.timeout for k, v in pairs(headers or {}) do req.headers:upsert(k, v) end local headers, stream = req:go(timeout) local body = stream:get_body_as_string() return body, headers end return setmetatable(req, meta) end --- returns a pre-configured cleartext `http2_client` for the Kong proxy port. -- @function proxy_client_h2c -- @return http2 client local function proxy_client_h2c() local proxy_ip = get_proxy_ip(false, true) local proxy_port = get_proxy_port(false, true) assert(proxy_ip, "No http-proxy found in the configuration") return http2_client(proxy_ip, proxy_port) end --- returns a pre-configured TLS `http2_client` for the Kong SSL proxy port. -- @function proxy_client_h2 -- @return http2 client local function proxy_client_h2() local proxy_ip = get_proxy_ip(true, true) local proxy_port = get_proxy_port(true, true) assert(proxy_ip, "No https-proxy found in the configuration") return http2_client(proxy_ip, proxy_port, true) end local exec -- forward declaration --- Creates a gRPC client, based on the grpcurl CLI. -- @function grpc_client -- @param host hostname to connect to -- @param port port to connect to -- @param opts table with options supported by grpcurl -- @return grpc client local function grpc_client(host, port, opts) local host = assert(host) local port = assert(tostring(port)) opts = opts or {} if not opts["-proto"] then opts["-proto"] = MOCK_GRPC_UPSTREAM_PROTO_PATH end return setmetatable({ opts = opts, cmd_template = string.format("bin/grpcurl %%s %s:%s %%s", host, port) }, { __call = function(t, args) local service = assert(args.service) local body = args.body local t_body = type(body) if t_body ~= "nil" then if t_body == "table" then body = cjson.encode(body) end args.opts["-d"] = string.format("'%s'", body) end local opts = gen_grpcurl_opts(pl_tablex.merge(t.opts, args.opts, true)) local ok, _, out, err = exec(string.format(t.cmd_template, opts, service), true) if ok then return ok, ("%s%s"):format(out or "", err or "") else return nil, ("%s%s"):format(out or "", err or "") end end }) end --- returns a pre-configured `grpc_client` for the Kong proxy port. -- @function proxy_client_grpc -- @param host hostname to connect to -- @param port port to connect to -- @return grpc client local function proxy_client_grpc(host, port) local proxy_ip = host or get_proxy_ip(false, true) local proxy_port = port or get_proxy_port(false, true) assert(proxy_ip, "No http-proxy found in the configuration") return grpc_client(proxy_ip, proxy_port, {["-plaintext"] = true}) end --- returns a pre-configured `grpc_client` for the Kong SSL proxy port. -- @function proxy_client_grpcs -- @param host hostname to connect to -- @param port port to connect to -- @return grpc client local function proxy_client_grpcs(host, port) local proxy_ip = host or get_proxy_ip(true, true) local proxy_port = port or get_proxy_port(true, true) assert(proxy_ip, "No https-proxy found in the configuration") return grpc_client(proxy_ip, proxy_port, {["-insecure"] = true}) end --- -- 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 {} 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.helpers.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 --- Starts a local HTTP server. -- Accepts a single connection and then closes. Sends a 200 ok, 'Connection: -- close' response. -- If the request received has path `/delay` then the response will be delayed -- by 2 seconds. -- @function http_server -- @tparam number port The port 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 -- @return A thread object (from the `llthreads2` Lua package) -- @see kill_http_server local function http_server(port, opts) local threads = require "llthreads2.ex" opts = opts or {} 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 client = assert(server:accept()) local lines = {} local line, err repeat line, err = client:receive("*l") if err then break else table.insert(lines, line) end until line == "" if #lines > 0 and lines[1] == "GET /delay HTTP/1.0" then ngx.sleep(2) end if err then server:close() error(err) end local body, _ = client:receive("*a") client:send("HTTP/1.1 200 OK\r\nConnection: close\r\n\r\n") client:close() server:close() return lines, body end }, port, opts) return thread:start() end --- Stops a local HTTP server. -- A server previously created with `http_server` can be stopped prematurely by -- calling this function. -- @function kill_http_server -- @param port the port the HTTP server is listening on. -- @see http_server local function kill_http_server(port) os.execute("fuser -n tcp -k " .. port) 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=MOCK_UPSTREAM_PORT] number port The port the server will be listening on -- @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" 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 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 -------------------- -- Custom assertions -- -- @section assertions local say = require "say" local luassert = require "luassert.assert" --- 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 type(f) ~= "function" then error("arg #1 must be a function", 2) end if timeout ~= nil and type(timeout) ~= "number" then error("arg #2 must be a number", 2) end if step ~= nil and type(step) ~= "number" then error("arg #3 must be a number", 2) end ngx.update_time() timeout = timeout or 5 step = step or 0.05 local tstart = ngx.time() local texp = tstart + timeout local ok, res, err repeat ok, res, err = pcall(f) ngx.sleep(step) ngx.update_time() until not ok or res or ngx.time() >= texp if not ok then -- report error from `f`, such as assert gone wrong error(tostring(res), 2) elseif not res and err then -- report a failure for `f` to meet its condition -- and eventually an error return value which could be the cause error("wait_until() timeout: " .. tostring(err) .. " (after delay: " .. timeout .. "s)", 2) elseif not res then -- report a failure for `f` to meet its condition error("wait_until() timeout (after delay " .. timeout .. "s)", 2) end end --- Same as `wait_until`, but does not stop retrying when Lua error occured local function pwait_until(f, timeout, step) wait_until(function() return pcall(f) end, timeout, 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[opt=2] number timeout maximum time to wait -- @tparam[opt] number admin_client_timeout, to override the default timeout setting -- @tparam[opt] number forced_admin_port to override the default port of admin API -- @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 = 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 = 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=30] number timeout maximum time to wait -- @tparam[opt] number admin_client_timeout, to override the default timeout setting -- @tparam[opt] number forced_admin_port to override the default port of admin API -- @usage helpers.wait_for_all_config_update() local function wait_for_all_config_update(timeout, admin_client_timeout, forced_admin_port) timeout = timeout or 30 local function call_admin_api(method, path, body, expected_status) local client = admin_client(admin_client_timeout, forced_admin_port) local res if string.upper(method) == "POST" then res = client:post(path, { headers = {["Content-Type"] = "application/json"}, body = body, }) elseif string.upper(method) == "DELETE" then res = client:delete(path) end local ok, json_or_nil_or_err = pcall(function () assert(res.status == expected_status, "unexpected response code") 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 upstream_name = "really.really.really.really.really.really.really.mocking.upstream.com" local service_name = "really-really-really-really-really-really-really-mocking-service" local route_path = "/really-really-really-really-really-really-really-mocking-route" local host = MOCK_UPSTREAM_HOST local port = MOCK_UPSTREAM_PORT -- create mocking upstream local res = assert(call_admin_api("POST", "/upstreams", { name = upstream_name }, 201)) 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)) target_id = res.id -- create mocking service to mocking upstream res = assert(call_admin_api("POST", "/services", { name = service_name, url = "http://" .. upstream_name .. "/anything" }, 201)) 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)) route_id = res.id -- wait for mocking route ready pwait_until(function () local proxy = proxy_client() res = proxy:get(route_path) local ok, err = pcall(assert, res.status == 200) proxy:close() return ok, err end, timeout / 2) -- delete mocking configurations call_admin_api("DELETE", "/routes/" .. route_id, nil, 204) call_admin_api("DELETE", "/services/" .. service_id, nil, 204) call_admin_api("DELETE", string.format("/upstreams/%s/targets/%s", upstream_id, target_id), nil, 204) call_admin_api("DELETE", "/upstreams/" .. upstream_id, nil, 204) -- wait for mocking configurations to be deleted pwait_until(function () local proxy = proxy_client() res = proxy:get(route_path) local ok, err = pcall(assert, res.status == 404) proxy:close() return ok, err end, timeout / 2) end --- Generic modifier "response". -- Will set a "response" value in the assertion state, so following -- assertions will operate on the value set. -- @function response -- @param response_obj results from `http_client:send` function (or any of the -- shortcuts `client:get`, `client:post`, etc). -- @usage -- local res = client:get("/request", { .. request options here ..}) -- local response_length = assert.response(res).has.header("Content-Length") local function modifier_response(state, arguments, level) assert(arguments.n > 0, "response modifier requires a response object as argument") local res = arguments[1] assert(type(res) == "table" and type(res.read_body) == "function", "response modifier requires a response object as argument, got: " .. tostring(res)) rawset(state, "kong_response", res) rawset(state, "kong_request", nil) return state end luassert:register("modifier", "response", modifier_response) --- Generic modifier "request". -- Will set a "request" value in the assertion state, so following -- assertions will operate on the value set. -- -- The request must be inside a 'response' from the `mock_upstream`. If a request -- is send to the `mock_upstream` endpoint `"/request"`, it will echo the request -- received in the body of the response. -- @function request -- @param response_obj results from `http_client:send` function (or any of the -- shortcuts `client:get`, `client:post`, etc). -- @usage -- local res = client:post("/request", { -- headers = { ["Content-Type"] = "application/json" }, -- body = { hello = "world" }, -- }) -- local request_length = assert.request(res).has.header("Content-Length") local function modifier_request(state, arguments, level) local generic = "The assertion 'request' modifier takes a http response" .. " object as input to decode the json-body returned by" .. " mock_upstream, to retrieve the proxied request." local res = arguments[1] assert(type(res) == "table" and type(res.read_body) == "function", "Expected a http response object, got '" .. tostring(res) .. "'. " .. generic) local body, request, err body = assert(res:read_body()) request, err = cjson.decode(body) assert(request, "Expected the http response object to have a json encoded body," .. " but decoding gave error '" .. tostring(err) .. "'. Obtained body: " .. body .. "\n." .. generic) if lookup((res.headers or {}),"X-Powered-By") ~= "mock_upstream" then error("Could not determine the response to be from mock_upstream") end rawset(state, "kong_request", request) rawset(state, "kong_response", nil) return state end luassert:register("modifier", "request", modifier_request) --- Generic fail assertion. A convenience function for debugging tests, always -- fails. It will output the values it was called with as a table, with an `n` -- field to indicate the number of arguments received. See also `intercept`. -- @function fail -- @param ... any set of parameters to be displayed with the failure -- @see intercept -- @usage -- assert.fail(some, value) local function fail(state, args) local out = {} for k,v in pairs(args) do out[k] = v end args[1] = out args.n = 1 return false end say:set("assertion.fail.negative", [[ Fail assertion was called with the following parameters (formatted as a table); %s ]]) luassert:register("assertion", "fail", fail, "assertion.fail.negative", "assertion.fail.negative") --- Assertion to check whether a value lives in an array. -- @function contains -- @param expected The value to search for -- @param array The array to search for the value -- @param pattern (optional) If truthy, then `expected` is matched as a Lua string -- pattern -- @return the array index at which the value was found -- @usage -- local arr = { "one", "three" } -- local i = assert.contains("one", arr) --> passes; i == 1 -- local i = assert.contains("two", arr) --> fails -- local i = assert.contains("ee$", arr, true) --> passes; i == 2 local function contains(state, args) local expected, arr, pattern = unpack(args) local found for i = 1, #arr do if (pattern and string.match(arr[i], expected)) or arr[i] == expected then found = i break end end return found ~= nil, {found} end say:set("assertion.contains.negative", [[ Expected array to contain element. Expected to contain: %s ]]) say:set("assertion.contains.positive", [[ Expected array to not contain element. Expected to not contain: %s ]]) luassert:register("assertion", "contains", contains, "assertion.contains.negative", "assertion.contains.positive") local deep_sort do local function deep_compare(a, b) if a == nil then a = "" end if b == nil then b = "" end deep_sort(a) deep_sort(b) if type(a) ~= type(b) then return type(a) < type(b) end if type(a) == "table" then return deep_compare(a[1], b[1]) end return a < b end function deep_sort(t) if type(t) == "table" then for _, v in pairs(t) do deep_sort(v) end table.sort(t, deep_compare) end return t end end --- Assertion to check the status-code of a http response. -- @function status -- @param expected the expected status code -- @param response (optional) results from `http_client:send` function, -- alternatively use `response`. -- @return the response body as a string, for a json body see `jsonbody`. -- @usage -- local res = assert(client:send { .. your request params here .. }) -- local body = assert.has.status(200, res) -- or alternativly -- local body = assert.response(res).has.status(200) -- does the same local function res_status(state, args) assert(not rawget(state, "kong_request"), "Cannot check statuscode against a request object," .. " only against a response object") local expected = args[1] local res = args[2] or rawget(state, "kong_response") assert(type(expected) == "number", "Expected response code must be a number value. Got: " .. tostring(expected)) assert(type(res) == "table" and type(res.read_body) == "function", "Expected a http_client response. Got: " .. tostring(res)) if expected ~= res.status then local body, err = res:read_body() if not body then body = "Error reading body: " .. err end table.insert(args, 1, pl_stringx.strip(body)) table.insert(args, 1, res.status) table.insert(args, 1, expected) args.n = 3 if res.status == 500 then -- on HTTP 500, we can try to read the server's error logs -- for debugging purposes (very useful for travis) local str = pl_file.read(conf.nginx_err_logs) if not str then return false -- no err logs to read in this prefix end local str_t = pl_stringx.splitlines(str) local first_line = #str_t - math.min(60, #str_t) + 1 local msg_t = {"\nError logs (" .. conf.nginx_err_logs .. "):"} for i = first_line, #str_t do msg_t[#msg_t+1] = str_t[i] end table.insert(args, 4, table.concat(msg_t, "\n")) args.n = 4 end return false else local body, err = res:read_body() local output = body if not output then output = "Error reading body: " .. err end output = pl_stringx.strip(output) table.insert(args, 1, output) table.insert(args, 1, res.status) table.insert(args, 1, expected) args.n = 3 return true, {pl_stringx.strip(body)} end end say:set("assertion.res_status.negative", [[ Invalid response status code. Status expected: %s Status received: %s Body: %s %s]]) say:set("assertion.res_status.positive", [[ Invalid response status code. Status not expected: %s Status received: %s Body: %s %s]]) luassert:register("assertion", "status", res_status, "assertion.res_status.negative", "assertion.res_status.positive") luassert:register("assertion", "res_status", res_status, "assertion.res_status.negative", "assertion.res_status.positive") --- Checks and returns a json body of an http response/request. Only checks -- validity of the json, does not check appropriate headers. Setting the target -- to check can be done through the `request` and `response` modifiers. -- -- For a non-json body, see the `status` assertion. -- @function jsonbody -- @return the decoded json as a table -- @usage -- local res = assert(client:send { .. your request params here .. }) -- local json_table = assert.response(res).has.jsonbody() local function jsonbody(state, args) assert(args[1] == nil and rawget(state, "kong_request") or rawget(state, "kong_response"), "the `jsonbody` assertion does not take parameters. " .. "Use the `response`/`require` modifiers to set the target to operate on") if rawget(state, "kong_response") then local body = rawget(state, "kong_response"):read_body() local json, err = cjson.decode(body) if not json then table.insert(args, 1, "Error decoding: " .. tostring(err) .. "\nResponse body:" .. body) args.n = 1 return false end return true, {json} else local r = rawget(state, "kong_request") if r.post_data and (r.post_data.kind == "json" or r.post_data.kind == "json (error)") and r.post_data.params then local pd = r.post_data return true, { { params = pd.params, data = pd.text, error = pd.error, kind = pd.kind } } else error("No json data found in the request") end end end say:set("assertion.jsonbody.negative", [[ Expected response body to contain valid json. Got: %s ]]) say:set("assertion.jsonbody.positive", [[ Expected response body to not contain valid json. Got: %s ]]) luassert:register("assertion", "jsonbody", jsonbody, "assertion.jsonbody.negative", "assertion.jsonbody.positive") --- Asserts that a named header in a `headers` subtable exists. -- Header name comparison is done case-insensitive. -- @function header -- @param name header name to look for (case insensitive). -- @see response -- @see request -- @return value of the header -- @usage -- local res = client:get("/request", { .. request options here ..}) -- local resp_header_value = assert.response(res).has.header("Content-Length") -- local req_header_value = assert.request(res).has.header("Content-Length") local function res_header(state, args) local header = args[1] local res = args[2] or rawget(state, "kong_request") or rawget(state, "kong_response") assert(type(res) == "table" and type(res.headers) == "table", "'header' assertion input does not contain a 'headers' subtable") local value = lookup(res.headers, header) table.insert(args, 1, res.headers) table.insert(args, 1, header) args.n = 2 if not value then return false end return true, {value} end say:set("assertion.res_header.negative", [[ Expected header: %s But it was not found in: %s ]]) say:set("assertion.res_header.positive", [[ Did not expected header: %s But it was found in: %s ]]) luassert:register("assertion", "header", res_header, "assertion.res_header.negative", "assertion.res_header.positive") --- -- An assertion to look for a query parameter in a query string. -- Parameter name comparison is done case-insensitive. -- @function queryparam -- @param name name of the query parameter to look up (case insensitive) -- @return value of the parameter -- @usage -- local res = client:get("/request", { -- query = { hello = "world" }, -- }) -- local param_value = assert.request(res).has.queryparam("hello") local function req_query_param(state, args) local param = args[1] local req = rawget(state, "kong_request") assert(req, "'queryparam' assertion only works with a request object") local params if type(req.uri_args) == "table" then params = req.uri_args else error("No query parameters found in request object") end local value = lookup(params, param) table.insert(args, 1, params) table.insert(args, 1, param) args.n = 2 if not value then return false end return true, {value} end say:set("assertion.req_query_param.negative", [[ Expected query parameter: %s But it was not found in: %s ]]) say:set("assertion.req_query_param.positive", [[ Did not expected query parameter: %s But it was found in: %s ]]) luassert:register("assertion", "queryparam", req_query_param, "assertion.req_query_param.negative", "assertion.req_query_param.positive") --- -- Adds an assertion to look for a urlencoded form parameter in a request. -- Parameter name comparison is done case-insensitive. Use the `request` modifier to set -- the request to operate on. -- @function formparam -- @param name name of the form parameter to look up (case insensitive) -- @return value of the parameter -- @usage -- local r = assert(proxy_client:post("/request", { -- body = { -- hello = "world", -- }, -- headers = { -- host = "mock_upstream", -- ["Content-Type"] = "application/x-www-form-urlencoded", -- }, -- }) -- local value = assert.request(r).has.formparam("hello") -- assert.are.equal("world", value) local function req_form_param(state, args) local param = args[1] local req = rawget(state, "kong_request") assert(req, "'formparam' assertion can only be used with a mock_upstream request object") local value if req.post_data and (req.post_data.kind == "form" or req.post_data.kind == "multipart-form") then value = lookup(req.post_data.params or {}, param) else error("Could not determine the request to be from either mock_upstream") end table.insert(args, 1, req) table.insert(args, 1, param) args.n = 2 if not value then return false end return true, {value} end say:set("assertion.req_form_param.negative", [[ Expected url encoded form parameter: %s But it was not found in request: %s ]]) say:set("assertion.req_form_param.positive", [[ Did not expected url encoded form parameter: %s But it was found in request: %s ]]) luassert:register("assertion", "formparam", req_form_param, "assertion.req_form_param.negative", "assertion.req_form_param.positive") --- -- Assertion to ensure a value is greater than a base value. -- @function is_gt -- @param base the base value to compare against -- @param value the value that must be greater than the base value local function is_gt(state, arguments) local expected = arguments[1] local value = arguments[2] arguments[1] = value arguments[2] = expected return value > expected end say:set("assertion.gt.negative", [[ Given value (%s) should be greater than expected value (%s) ]]) say:set("assertion.gt.positive", [[ Given value (%s) should not be greater than expected value (%s) ]]) luassert:register("assertion", "gt", is_gt, "assertion.gt.negative", "assertion.gt.positive") --- Generic modifier "certificate". -- Will set a "certificate" value in the assertion state, so following -- assertions will operate on the value set. -- @function certificate -- @param cert The cert text -- @see cn -- @usage -- assert.certificate(cert).has.cn("ssl-example.com") local function modifier_certificate(state, arguments, level) local generic = "The assertion 'certficate' modifier takes a cert text" .. " as input to validate certificate parameters" .. " against." local cert = arguments[1] assert(type(cert) == "string", "Expected a certificate text, got '" .. tostring(cert) .. "'. " .. generic) rawset(state, "kong_certificate", cert) return state end luassert:register("modifier", "certificate", modifier_certificate) --- Assertion to check whether a CN is matched in an SSL cert. -- @function cn -- @param expected The CN value -- @param cert The cert text -- @return the CN found in the cert -- @see certificate -- @usage -- assert.cn("ssl-example.com", cert) -- -- -- alternative: -- assert.certificate(cert).has.cn("ssl-example.com") local function assert_cn(state, args) local expected = args[1] if args[2] and rawget(state, "kong_certificate") then error("assertion 'cn' takes either a 'certificate' modifier, or 2 parameters, not both") end local cert = args[2] or rawget(state, "kong_certificate") local cn = string.match(cert, "CN%s*=%s*([^%s,]+)") args[2] = cn or "(CN not found in certificate)" args.n = 2 return cn == expected end say:set("assertion.cn.negative", [[ Expected certificate to have the given CN value. Expected CN: %s Got instead: %s ]]) say:set("assertion.cn.positive", [[ Expected certificate to not have the given CN value. Expected CN to not be: %s Got instead: %s ]]) luassert:register("assertion", "cn", assert_cn, "assertion.cn.negative", "assertion.cn.positive") do --- Generic modifier "logfile" -- Will set an "errlog_path" value in the assertion state. -- @function logfile -- @param path A path to the log file (defaults to the test prefix's -- errlog). -- @see line -- @see clean_logfile -- @usage -- assert.logfile("./my/logfile.log").has.no.line("[error]", true) local function modifier_errlog(state, args) local errlog_path = args[1] or conf.nginx_err_logs assert(type(errlog_path) == "string", "logfile modifier expects nil, or " .. "a string as argument, got: " .. type(errlog_path)) rawset(state, "errlog_path", errlog_path) return state end luassert:register("modifier", "errlog", modifier_errlog) -- backward compat luassert:register("modifier", "logfile", modifier_errlog) --- Assertion checking if any line from a file matches the given regex or -- substring. -- @function line -- @param regex The regex to evaluate against each line. -- @param plain If true, the regex argument will be considered as a plain -- string. -- @param timeout An optional timeout after which the assertion will fail if -- reached. -- @param fpath An optional path to the file (defaults to the filelog -- modifier) -- @see logfile -- @see clean_logfile -- @usage -- helpers.clean_logfile() -- -- -- run some tests here -- -- assert.logfile().has.no.line("[error]", true) local function match_line(state, args) local regex = args[1] local plain = args[2] local timeout = args[3] or 2 local fpath = args[4] or rawget(state, "errlog_path") assert(type(regex) == "string", "Expected the regex argument to be a string") assert(type(fpath) == "string", "Expected the file path argument to be a string") assert(type(timeout) == "number" and timeout > 0, "Expected the timeout argument to be a positive number") local pok = pcall(wait_until, function() local logs = pl_file.read(fpath) local from, _, err for line in logs:gmatch("[^\r\n]+") do if plain then from = string.find(line, regex, nil, true) else from, _, err = ngx.re.find(line, regex) if err then error(err) end end if from then table.insert(args, 1, line) table.insert(args, 1, regex) args.n = 2 return true end end end, timeout) table.insert(args, 1, fpath) args.n = args.n + 1 return pok end say:set("assertion.match_line.negative", unindent [[ Expected file at: %s To match: %s ]]) say:set("assertion.match_line.positive", unindent [[ Expected file at: %s To not match: %s But matched line: %s ]]) luassert:register("assertion", "line", match_line, "assertion.match_line.negative", "assertion.match_line.positive") end ---------------- -- DNS-record mocking. -- These function allow to create mock dns records that the test Kong instance -- will use to resolve names. The created mocks are injected by the `start_kong` -- function. -- @usage -- -- Create a new DNS mock and add some DNS records -- local fixtures = { -- dns_mock = helpers.dns_mock.new { mocks_only = true } -- } -- -- fixtures.dns_mock:SRV { -- name = "my.srv.test.com", -- target = "a.my.srv.test.com", -- port = 80, -- } -- fixtures.dns_mock:SRV { -- name = "my.srv.test.com", -- adding same name again: record gets 2 entries! -- target = "b.my.srv.test.com", -- a.my.srv.test.com and b.my.srv.test.com -- port = 8080, -- } -- fixtures.dns_mock:A { -- name = "a.my.srv.test.com", -- address = "127.0.0.1", -- } -- fixtures.dns_mock:A { -- name = "b.my.srv.test.com", -- address = "127.0.0.1", -- } -- @section DNS-mocks local dns_mock = {} do dns_mock.__index = dns_mock dns_mock.__tostring = function(self) -- fill array to prevent json encoding errors local out = { mocks_only = self.mocks_only, records = {} } for i = 1, 33 do out.records[i] = self[i] or {} end local json = assert(cjson.encode(out)) return json end local TYPE_A, TYPE_AAAA, TYPE_CNAME, TYPE_SRV = 1, 28, 5, 33 --- Creates a new DNS mock. -- The options table supports the following fields: -- -- - `mocks_only`: boolean, if set to `true` then only mock records will be -- returned. If `falsy` it will fall through to an actual DNS lookup. -- @function dns_mock.new -- @param options table with mock options -- @return dns_mock object -- @usage -- local mock = helpers.dns_mock.new { mocks_only = true } function dns_mock.new(options) return setmetatable(options or {}, dns_mock) end --- Adds an SRV record to the DNS mock. -- Fields `name`, `target`, and `port` are required. Other fields get defaults: -- -- * `weight`; 20 -- * `ttl`; 600 -- * `priority`; 20 -- @param rec the mock DNS record to insert -- @return true function dns_mock:SRV(rec) if self == dns_mock then error("can't operate on the class, you must create an instance", 2) end if getmetatable(self or {}) ~= dns_mock then error("SRV method must be called using the colon notation", 2) end assert(rec, "Missing record parameter") local name = assert(rec.name, "No name field in SRV record") self[TYPE_SRV] = self[TYPE_SRV] or {} local query_answer = self[TYPE_SRV][name] if not query_answer then query_answer = {} self[TYPE_SRV][name] = query_answer end table.insert(query_answer, { type = TYPE_SRV, name = name, target = assert(rec.target, "No target field in SRV record"), port = assert(rec.port, "No port field in SRV record"), weight = rec.weight or 10, ttl = rec.ttl or 600, priority = rec.priority or 20, class = rec.class or 1 }) return true end --- Adds an A record to the DNS mock. -- Fields `name` and `address` are required. Other fields get defaults: -- -- * `ttl`; 600 -- @param rec the mock DNS record to insert -- @return true function dns_mock:A(rec) if self == dns_mock then error("can't operate on the class, you must create an instance", 2) end if getmetatable(self or {}) ~= dns_mock then error("A method must be called using the colon notation", 2) end assert(rec, "Missing record parameter") local name = assert(rec.name, "No name field in A record") self[TYPE_A] = self[TYPE_A] or {} local query_answer = self[TYPE_A][name] if not query_answer then query_answer = {} self[TYPE_A][name] = query_answer end table.insert(query_answer, { type = TYPE_A, name = name, address = assert(rec.address, "No address field in A record"), ttl = rec.ttl or 600, class = rec.class or 1 }) return true end --- Adds an AAAA record to the DNS mock. -- Fields `name` and `address` are required. Other fields get defaults: -- -- * `ttl`; 600 -- @param rec the mock DNS record to insert -- @return true function dns_mock:AAAA(rec) if self == dns_mock then error("can't operate on the class, you must create an instance", 2) end if getmetatable(self or {}) ~= dns_mock then error("AAAA method must be called using the colon notation", 2) end assert(rec, "Missing record parameter") local name = assert(rec.name, "No name field in AAAA record") self[TYPE_AAAA] = self[TYPE_AAAA] or {} local query_answer = self[TYPE_AAAA][name] if not query_answer then query_answer = {} self[TYPE_AAAA][name] = query_answer end table.insert(query_answer, { type = TYPE_AAAA, name = name, address = assert(rec.address, "No address field in AAAA record"), ttl = rec.ttl or 600, class = rec.class or 1 }) return true end --- Adds a CNAME record to the DNS mock. -- Fields `name` and `cname` are required. Other fields get defaults: -- -- * `ttl`; 600 -- @param rec the mock DNS record to insert -- @return true function dns_mock:CNAME(rec) if self == dns_mock then error("can't operate on the class, you must create an instance", 2) end if getmetatable(self or {}) ~= dns_mock then error("CNAME method must be called using the colon notation", 2) end assert(rec, "Missing record parameter") local name = assert(rec.name, "No name field in CNAME record") self[TYPE_CNAME] = self[TYPE_CNAME] or {} local query_answer = self[TYPE_CNAME][name] if not query_answer then query_answer = {} self[TYPE_CNAME][name] = query_answer end table.insert(query_answer, { type = TYPE_CNAME, name = name, cname = assert(rec.cname, "No cname field in CNAME record"), ttl = rec.ttl or 600, class = rec.class or 1 }) return true end end ---------------- -- Shell helpers -- @section Shell-helpers --- Execute a command. -- Modified version of `pl.utils.executeex()` so the output can directly be -- used on an assertion. -- @function execute -- @param cmd command string to execute -- @param pl_returns (optional) boolean: if true, this function will -- return the same values as Penlight's executeex. -- @return if `pl_returns` is true, returns four return values -- (ok, code, stdout, stderr); if `pl_returns` is false, -- returns either (false, stderr) or (true, stderr, stdout). function exec(cmd, pl_returns) local ok, code, stdout, stderr = pl_utils.executeex(cmd) if pl_returns then return ok, code, stdout, stderr end if not ok then stdout = nil -- don't return 3rd value if fail because of busted's `assert` end return ok, stderr, stdout end --- Execute a Kong command. -- @function kong_exec -- @param cmd Kong command to execute, eg. `start`, `stop`, etc. -- @param env (optional) table with kong parameters to set as environment -- variables, overriding the test config (each key will automatically be -- prefixed with `KONG_` and be converted to uppercase) -- @param pl_returns (optional) boolean: if true, this function will -- return the same values as Penlight's `executeex`. -- @param env_vars (optional) a string prepended to the command, so -- that arbitrary environment variables may be passed -- @return if `pl_returns` is true, returns four return values -- (ok, code, stdout, stderr); if `pl_returns` is false, -- returns either (false, stderr) or (true, stderr, stdout). function kong_exec(cmd, env, pl_returns, env_vars) cmd = cmd or "" env = env or {} -- Insert the Lua path to the custom-plugin fixtures do local function cleanup(t) if t then t = pl_stringx.strip(t) if t:sub(-1,-1) == ";" then t = t:sub(1, -2) end end return t ~= "" and t or nil end local paths = {} table.insert(paths, cleanup(CUSTOM_PLUGIN_PATH)) table.insert(paths, cleanup(CUSTOM_VAULT_PATH)) table.insert(paths, cleanup(env.lua_package_path)) table.insert(paths, cleanup(conf.lua_package_path)) env.lua_package_path = table.concat(paths, ";") -- note; the nginx config template will add a final ";;", so no need to -- include that here end if not env.plugins then env.plugins = "bundled,dummy,cache,rewriter,error-handler-log," .. "error-generator,error-generator-last," .. "short-circuit" end -- build Kong environment variables env_vars = env_vars or "" for k, v in pairs(env) do env_vars = string.format("%s KONG_%s='%s'", env_vars, k:upper(), v) end return exec(env_vars .. " " .. BIN_PATH .. " " .. cmd, pl_returns) end --- Prepares the Kong environment. -- Creates the working directory if it does not exist. -- @param prefix (optional) path to the working directory, if omitted the test -- configuration will be used -- @function prepare_prefix local function prepare_prefix(prefix) return pl_dir.makepath(prefix or conf.prefix) end --- Cleans the Kong environment. -- Deletes the working directory if it exists. -- @param prefix (optional) path to the working directory, if omitted the test -- configuration will be used -- @function clean_prefix local function clean_prefix(prefix) prefix = prefix or conf.prefix if pl_path.exists(prefix) then pl_dir.rmtree(prefix) end end -- Reads the pid from a pid file and returns it, or nil + err local function get_pid_from_file(pid_path) local pid local fd, err = io.open(pid_path) if not fd then return nil, err end pid = fd:read("*l") fd:close() return pid end local function pid_dead(pid, timeout) local max_time = ngx.now() + (timeout or 10) repeat if not pl_utils.execute("ps -p " .. pid .. " >/dev/null 2>&1") then return true end -- still running, wait some more ngx.sleep(0.05) until ngx.now() >= max_time return false end -- Waits for the termination of a pid. -- @param pid_path Filename of the pid file. -- @param timeout (optional) in seconds, defaults to 10. local function wait_pid(pid_path, timeout, is_retry) local pid = get_pid_from_file(pid_path) if pid then if pid_dead(pid, timeout) then return end if is_retry then return end -- Timeout reached: kill with SIGKILL pl_utils.execute("kill -9 " .. pid .. " >/dev/null 2>&1") -- Sanity check: check pid again, but don't loop. wait_pid(pid_path, timeout, true) end end --- Return the actual configuration running at the given prefix. -- It may differ from the default, as it may have been modified -- by the `env` table given to start_kong. -- @function get_running_conf -- @param prefix (optional) The prefix path where the kong instance is running, -- defaults to the prefix in the default config. -- @return The conf table of the running instance, or nil + error. local function get_running_conf(prefix) local default_conf = conf_loader(nil, {prefix = prefix or conf.prefix}) return conf_loader.load_config_file(default_conf.kong_env) end --- Clears the logfile. Will overwrite the logfile with an empty file. -- @function clean_logfile -- @param logfile (optional) filename to clear, defaults to the current -- error-log file -- @return nothing -- @see line local function clean_logfile(logfile) logfile = logfile or (get_running_conf() or conf).nginx_err_logs os.execute(":> " .. logfile) end --- Return the actual Kong version the tests are running against. -- See [version.lua](https://github.com/kong/version.lua) for the format. This -- is mostly useful for testing plugins that should work with multiple Kong versions. -- @function get_version -- @return a `version` object -- @usage -- local version = require 'version' -- if helpers.get_version() < version("0.15.0") then -- -- do something -- end local function get_version() return version(select(3, assert(kong_exec("version")))) end local function render_fixtures(conf, env, prefix, fixtures) if fixtures and (fixtures.http_mock or fixtures.stream_mock) then -- prepare the prefix so we get the full config in the -- hidden `.kong_env` file, including test specified env vars etc assert(kong_exec("prepare --conf " .. conf, env)) local render_config = assert(conf_loader(prefix .. "/.kong_env", nil, { from_kong_env = true })) for _, mocktype in ipairs { "http_mock", "stream_mock" } do for filename, contents in pairs(fixtures[mocktype] or {}) do -- render the file using the full configuration contents = assert(prefix_handler.compile_conf(render_config, contents)) -- write file to prefix filename = prefix .. "/" .. filename .. "." .. mocktype assert(pl_utils.writefile(filename, contents)) end end end if fixtures and fixtures.dns_mock then -- write the mock records to the prefix assert(getmetatable(fixtures.dns_mock) == dns_mock, "expected dns_mock to be of a helpers.dns_mock class") assert(pl_utils.writefile(prefix .. "/dns_mock_records.json", tostring(fixtures.dns_mock))) -- add the mock resolver to the path to ensure the records are loaded if env.lua_package_path then env.lua_package_path = DNS_MOCK_LUA_PATH .. ";" .. env.lua_package_path else env.lua_package_path = DNS_MOCK_LUA_PATH end else -- remove any old mocks if they exist os.remove(prefix .. "/dns_mock_records.json") end return true end local function build_go_plugins(path) if pl_path.exists(pl_path.join(path, "go.mod")) then local ok, _, _, stderr = pl_utils.executeex(string.format( "cd %s; go mod tidy; go mod download", path)) assert(ok, stderr) end for _, go_source in ipairs(pl_dir.getfiles(path, "*.go")) do local ok, _, _, stderr = pl_utils.executeex(string.format( "cd %s; go build %s", path, pl_path.basename(go_source) )) assert(ok, stderr) end end local function isnewer(path_a, path_b) if not pl_path.exists(path_a) then return true end if not pl_path.exists(path_b) then return false end return assert(pl_path.getmtime(path_b)) > assert(pl_path.getmtime(path_a)) end local function make(workdir, specs) workdir = pl_path.normpath(workdir or pl_path.currentdir()) for _, spec in ipairs(specs) do local targetpath = pl_path.join(workdir, spec.target) for _, src in ipairs(spec.src) do local srcpath = pl_path.join(workdir, src) if isnewer(targetpath, srcpath) then local ok, _, _, stderr = pl_utils.executeex(string.format("cd %s; %s", workdir, spec.cmd)) assert(ok, stderr) if isnewer(targetpath, srcpath) then error(string.format("couldn't make %q newer than %q", targetpath, srcpath)) end break end end end return true end local grpc_target_proc local function start_grpc_target() local ngx_pipe = require "ngx.pipe" assert(make(GRPC_TARGET_SRC_PATH, { { target = "targetservice/targetservice.pb.go", src = { "../targetservice.proto" }, cmd = "protoc --go_out=. --go-grpc_out=. -I ../ ../targetservice.proto", }, { target = "targetservice/targetservice_grpc.pb.go", src = { "../targetservice.proto" }, cmd = "protoc --go_out=. --go-grpc_out=. -I ../ ../targetservice.proto", }, { target = "target", src = { "grpc-target.go", "targetservice/targetservice.pb.go", "targetservice/targetservice_grpc.pb.go" }, cmd = "go mod tidy && go mod download all && go build", }, })) grpc_target_proc = assert(ngx_pipe.spawn({ GRPC_TARGET_SRC_PATH .. "/target" }, { merge_stderr = true, })) return true end local function stop_grpc_target() if grpc_target_proc then grpc_target_proc:kill(resty_signal.signum("QUIT")) grpc_target_proc = nil end end local function get_grpc_target_port() return 15010 end --- Start the Kong instance to test against. -- The fixtures passed to this function can be 3 types: -- -- * DNS mocks -- -- * Nginx server blocks to be inserted in the http module -- -- * Nginx server blocks to be inserted in the stream module -- @function start_kong -- @param env table with Kong configuration parameters (and values) -- @param tables list of database tables to truncate before starting -- @param preserve_prefix (boolean) if truthy, the prefix will not be cleaned -- before starting -- @param fixtures tables with fixtures, dns, http and stream mocks. -- @return return values from `execute` -- @usage -- -- example mocks -- -- Create a new DNS mock and add some DNS records -- local fixtures = { -- http_mock = {}, -- stream_mock = {}, -- dns_mock = helpers.dns_mock.new() -- } -- -- fixtures.dns_mock:A { -- name = "a.my.srv.test.com", -- address = "127.0.0.1", -- } -- -- -- The blocks below will be rendered by the Kong template renderer, like other -- -- custom Kong templates. Hence the `${{xxxx}}` values. -- -- Multiple mocks can be added each under their own filename ("my_server_block" below) -- fixtures.http_mock.my_server_block = [[ -- server { -- server_name my_server; -- listen 10001 ssl; -- -- ssl_certificate ${{SSL_CERT}}; -- ssl_certificate_key ${{SSL_CERT_KEY}}; -- ssl_protocols TLSv1.1 TLSv1.2 TLSv1.3; -- -- location ~ "/echobody" { -- content_by_lua_block { -- ngx.req.read_body() -- local echo = ngx.req.get_body_data() -- ngx.status = status -- ngx.header["Content-Length"] = #echo + 1 -- ngx.say(echo) -- } -- } -- } -- ]] -- -- fixtures.stream_mock.my_server_block = [[ -- server { -- -- insert stream server config here -- } -- ]] -- -- assert(helpers.start_kong( {database = "postgres"}, nil, nil, fixtures)) local function start_kong(env, tables, preserve_prefix, fixtures) if tables ~= nil and type(tables) ~= "table" then error("arg #2 must be a list of tables to truncate") end env = env or {} local prefix = env.prefix or conf.prefix -- go plugins are enabled -- compile fixture go plugins if any setting mentions it for _,v in pairs(env) do if type(v) == "string" and v:find(GO_PLUGIN_PATH) then build_go_plugins(GO_PLUGIN_PATH) break end end -- note: set env var "KONG_TEST_DONT_CLEAN" !! the "_TEST" will be dropped if not (preserve_prefix or os.getenv("KONG_DONT_CLEAN")) then clean_prefix(prefix) end local ok, err = prepare_prefix(prefix) if not ok then return nil, err end truncate_tables(db, tables) local nginx_conf = "" if env.nginx_conf then nginx_conf = " --nginx-conf " .. env.nginx_conf end if dcbp and not env.declarative_config and not env.declarative_config_string then if not config_yml then config_yml = prefix .. "/config.yml" local cfg = dcbp.done() local declarative = require "kong.db.declarative" local ok, err = declarative.to_yaml_file(cfg, config_yml) if not ok then return nil, err end end env = utils.deep_copy(env) env.declarative_config = config_yml end assert(render_fixtures(TEST_CONF_PATH .. nginx_conf, env, prefix, fixtures)) return kong_exec("start --conf " .. TEST_CONF_PATH .. nginx_conf, env) end -- Stop the Kong test instance. -- @function stop_kong -- @param prefix (optional) the prefix where the test instance runs, defaults to the test configuration. -- @param preserve_prefix (boolean) if truthy, the prefix will not be deleted after stopping -- @param preserve_dc -- @return true or nil+err local function stop_kong(prefix, preserve_prefix, preserve_dc) prefix = prefix or conf.prefix local running_conf, err = get_running_conf(prefix) if not running_conf then return nil, err end local pid, err = get_pid_from_file(running_conf.nginx_pid) if not pid then return nil, err end local ok, _, _, err = pl_utils.executeex("kill -TERM " .. pid) if not ok then return nil, err end wait_pid(running_conf.nginx_pid) -- note: set env var "KONG_TEST_DONT_CLEAN" !! the "_TEST" will be dropped if not (preserve_prefix or os.getenv("KONG_DONT_CLEAN")) then clean_prefix(prefix) end if not preserve_dc then config_yml = nil end ngx.ctx.workspace = nil return true end --- Restart Kong. Reusing declarative config when using `database=off`. -- @function restart_kong -- @param env see `start_kong` -- @param tables see `start_kong` -- @param fixtures see `start_kong` -- @return true or nil+err local function restart_kong(env, tables, fixtures) stop_kong(env.prefix, true, true) return start_kong(env, tables, true, fixtures) end local function wait_until_no_common_workers(workers, expected_total, strategy) if strategy == "cassandra" then ngx.sleep(0.5) end wait_until(function() local pok, admin_client = pcall(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) end local function get_kong_workers() local workers wait_until(function() local pok, admin_client = pcall(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 = json.pids.workers return true end, 10) return workers end --- Reload Kong and wait all workers are restarted. local function reload_kong(strategy, ...) local workers = get_kong_workers() local ok, err = kong_exec(...) if ok then wait_until_no_common_workers(workers, 1, strategy) end return ok, err end local function clustering_client_json(opts) assert(opts.host) assert(opts.port) assert(opts.cert) assert(opts.cert_key) local c = assert(ws_client:new()) local uri = "wss://" .. opts.host .. ":" .. opts.port .. "/v1/outlet?node_id=" .. (opts.node_id or utils.uuid()) .. "&node_hostname=" .. (opts.node_hostname or kong.node.get_hostname()) .. "&node_version=" .. (opts.node_version or KONG_VERSION) local conn_opts = { ssl_verify = false, -- needed for busted tests as CP certs are not trusted by the CLI client_cert = assert(ssl.parse_pem_cert(assert(pl_file.read(opts.cert)))), client_priv_key = assert(ssl.parse_pem_priv_key(assert(pl_file.read(opts.cert_key)))), server_name = "kong_clustering", } local res, err = c:connect(uri, conn_opts) if not res then return nil, err end local payload = assert(cjson.encode({ type = "basic_info", plugins = opts.node_plugins_list or PLUGINS_LIST, })) assert(c:send_binary(payload)) assert(c:send_ping(string.rep("0", 32))) local data, typ, err data, typ, err = c:recv_frame() c:close() if typ == "binary" then local odata = assert(utils.inflate_gzip(data)) local msg = assert(cjson.decode(odata)) return msg elseif typ == "pong" then return "PONG" end return nil, "unknown frame from CP: " .. (typ or err) end local clustering_client_wrpc do local wrpc = require("kong.tools.wrpc") local wrpc_proto = require("kong.tools.wrpc.proto") local semaphore = require("ngx.semaphore") local wrpc_services local function get_services() if not wrpc_services then wrpc_services = wrpc_proto.new() -- init_negotiation_client(wrpc_services) wrpc_services:import("kong.services.config.v1.config") wrpc_services:set_handler("ConfigService.SyncConfig", function(peer, data) peer.data = data peer.smph:post() return { accepted = true } end) end return wrpc_services end function clustering_client_wrpc(opts) assert(opts.host) assert(opts.port) assert(opts.cert) assert(opts.cert_key) local WS_OPTS = { timeout = opts.clustering_timeout, max_payload_len = opts.cluster_max_payload, } local c = assert(ws_client:new(WS_OPTS)) local uri = "wss://" .. opts.host .. ":" .. opts.port .. "/v1/wrpc?node_id=" .. (opts.node_id or utils.uuid()) .. "&node_hostname=" .. (opts.node_hostname or kong.node.get_hostname()) .. "&node_version=" .. (opts.node_version or KONG_VERSION) local conn_opts = { ssl_verify = false, -- needed for busted tests as CP certs are not trusted by the CLI client_cert = assert(ssl.parse_pem_cert(assert(pl_file.read(opts.cert)))), client_priv_key = assert(ssl.parse_pem_priv_key(assert(pl_file.read(opts.cert_key)))), protocols = "wrpc.konghq.com", } conn_opts.server_name = "kong_clustering" local ok, err = c:connect(uri, conn_opts) if not ok then return nil, err end local peer = wrpc.new_peer(c, get_services()) peer.smph = semaphore.new(0) peer:spawn_threads() local resp = assert(peer:call_async("ConfigService.ReportMetadata", { plugins = opts.node_plugins_list or PLUGINS_LIST })) if resp.ok then peer.smph:wait(2) if peer.data then peer:close() return peer.data end end return resp end end --- Simulate a Hybrid mode DP and connect to the CP specified in `opts`. -- @function clustering_client -- @param opts Options to use, the `host`, `port`, `cert` and `cert_key` fields -- are required. -- Other fields that can be overwritten are: -- `node_hostname`, `node_id`, `node_version`, `node_plugins_list`. If absent, -- they are automatically filled. -- @return msg if handshake succeeded and initial message received from CP or nil, err local function clustering_client(opts) if opts.cluster_protocol == "wrpc" then return clustering_client_wrpc(opts) else return clustering_client_json(opts) end end --- Return a table of clustering_protocols and -- create the appropriate Nginx template file if needed. -- The file pointed by `json`, when used by CP, -- will cause CP's wRPC endpoint be disabled. local function get_clustering_protocols() local confs = { wrpc = "spec/fixtures/custom_nginx.template", json = "/tmp/custom_nginx_no_wrpc.template", ["json (by switch)"] = "spec/fixtures/custom_nginx.template", } -- disable wrpc in CP os.execute(string.format("cat %s | sed 's/wrpc/foobar/g' > %s", confs.wrpc, confs.json)) return confs end ---------------- -- Variables/constants -- @section exported-fields --- Below is a list of fields/constants exported on the `helpers` module table: -- @table helpers -- @field dir The [`pl.dir` module of Penlight](http://tieske.github.io/Penlight/libraries/pl.dir.html) -- @field path The [`pl.path` module of Penlight](http://tieske.github.io/Penlight/libraries/pl.path.html) -- @field file The [`pl.file` module of Penlight](http://tieske.github.io/Penlight/libraries/pl.file.html) -- @field utils The [`pl.utils` module of Penlight](http://tieske.github.io/Penlight/libraries/pl.utils.html) -- @field test_conf The Kong test configuration. See also `get_running_conf` which might be slightly different. -- @field test_conf_path The configuration file in use. -- @field mock_upstream_hostname -- @field mock_upstream_protocol -- @field mock_upstream_host -- @field mock_upstream_port -- @field mock_upstream_url Base url constructed from the components -- @field mock_upstream_ssl_protocol -- @field mock_upstream_ssl_host -- @field mock_upstream_ssl_port -- @field mock_upstream_ssl_url Base url constructed from the components -- @field mock_upstream_stream_port -- @field mock_upstream_stream_ssl_port -- @field mock_grpc_upstream_proto_path -- @field grpcbin_host The host for grpcbin service, it can be set by env KONG_SPEC_TEST_GRPCBIN_HOST. -- @field grpcbin_port The port (SSL disabled) for grpcbin service, it can be set by env KONG_SPEC_TEST_GRPCBIN_PORT. -- @field grpcbin_ssl_port The port (SSL enabled) for grpcbin service it can be set by env KONG_SPEC_TEST_GRPCBIN_SSL_PORT. -- @field grpcbin_url The URL (SSL disabled) for grpcbin service -- @field grpcbin_ssl_url The URL (SSL enabled) for grpcbin service -- @field redis_host The host for Redis, it can be set by env KONG_SPEC_TEST_REDIS_HOST. -- @field redis_port The port (SSL disabled) for Redis, it can be set by env KONG_SPEC_TEST_REDIS_PORT. -- @field redis_ssl_port The port (SSL enabled) for Redis, it can be set by env KONG_SPEC_TEST_REDIS_SSL_PORT. -- @field redis_ssl_sni The server name for Redis, it can be set by env KONG_SPEC_TEST_REDIS_SSL_SNI. -- @field zipkin_host The host for Zipkin service, it can be set by env KONG_SPEC_TEST_ZIPKIN_HOST. -- @field zipkin_port the port for Zipkin service, it can be set by env KONG_SPEC_TEST_ZIPKIN_PORT. -- @field otelcol_host The host for OpenTelemetry Collector service, it can be set by env KONG_SPEC_TEST_OTELCOL_HOST. -- @field otelcol_http_port the port for OpenTelemetry Collector service, it can be set by env KONG_SPEC_TEST_OTELCOL_HTTP_PORT. -- @field otelcol_zpages_port the port for OpenTelemetry Collector Zpages service, it can be set by env KONG_SPEC_TEST_OTELCOL_ZPAGES_PORT. -- @field otelcol_file_exporter_path the path of for OpenTelemetry Collector's file exporter, it can be set by env KONG_SPEC_TEST_OTELCOL_FILE_EXPORTER_PATH. ---------- -- Exposed ---------- -- @export return { -- Penlight dir = pl_dir, path = pl_path, file = pl_file, utils = pl_utils, -- Kong testing properties db = db, blueprints = blueprints, get_db_utils = get_db_utils, get_cache = get_cache, bootstrap_database = bootstrap_database, bin_path = BIN_PATH, test_conf = conf, test_conf_path = TEST_CONF_PATH, go_plugin_path = GO_PLUGIN_PATH, mock_upstream_hostname = MOCK_UPSTREAM_HOSTNAME, mock_upstream_protocol = MOCK_UPSTREAM_PROTOCOL, mock_upstream_host = MOCK_UPSTREAM_HOST, mock_upstream_port = MOCK_UPSTREAM_PORT, mock_upstream_url = MOCK_UPSTREAM_PROTOCOL .. "://" .. MOCK_UPSTREAM_HOST .. ':' .. MOCK_UPSTREAM_PORT, mock_upstream_ssl_protocol = MOCK_UPSTREAM_SSL_PROTOCOL, mock_upstream_ssl_host = MOCK_UPSTREAM_HOST, mock_upstream_ssl_port = MOCK_UPSTREAM_SSL_PORT, mock_upstream_ssl_url = MOCK_UPSTREAM_SSL_PROTOCOL .. "://" .. MOCK_UPSTREAM_HOST .. ':' .. MOCK_UPSTREAM_SSL_PORT, mock_upstream_stream_port = MOCK_UPSTREAM_STREAM_PORT, mock_upstream_stream_ssl_port = MOCK_UPSTREAM_STREAM_SSL_PORT, mock_grpc_upstream_proto_path = MOCK_GRPC_UPSTREAM_PROTO_PATH, zipkin_host = ZIPKIN_HOST, zipkin_port = ZIPKIN_PORT, otelcol_host = OTELCOL_HOST, otelcol_http_port = OTELCOL_HTTP_PORT, otelcol_zpages_port = OTELCOL_ZPAGES_PORT, otelcol_file_exporter_path = OTELCOL_FILE_EXPORTER_PATH, grpcbin_host = GRPCBIN_HOST, grpcbin_port = GRPCBIN_PORT, grpcbin_ssl_port = GRPCBIN_SSL_PORT, grpcbin_url = string.format("grpc://%s:%d", GRPCBIN_HOST, GRPCBIN_PORT), grpcbin_ssl_url = string.format("grpcs://%s:%d", GRPCBIN_HOST, GRPCBIN_SSL_PORT), redis_host = REDIS_HOST, redis_port = REDIS_PORT, redis_ssl_port = REDIS_SSL_PORT, redis_ssl_sni = REDIS_SSL_SNI, blackhole_host = BLACKHOLE_HOST, -- Kong testing helpers execute = exec, dns_mock = dns_mock, kong_exec = kong_exec, get_version = get_version, get_running_conf = get_running_conf, http_client = http_client, grpc_client = grpc_client, http2_client = http2_client, wait_until = wait_until, pwait_until = pwait_until, wait_pid = wait_pid, wait_timer = wait_timer, wait_for_all_config_update = wait_for_all_config_update, tcp_server = tcp_server, udp_server = udp_server, kill_tcp_server = kill_tcp_server, http_server = http_server, kill_http_server = kill_http_server, get_proxy_ip = get_proxy_ip, get_proxy_port = get_proxy_port, proxy_client = proxy_client, proxy_client_grpc = proxy_client_grpc, proxy_client_grpcs = proxy_client_grpcs, proxy_client_h2c = proxy_client_h2c, proxy_client_h2 = proxy_client_h2, admin_client = admin_client, proxy_ssl_client = proxy_ssl_client, admin_ssl_client = admin_ssl_client, prepare_prefix = prepare_prefix, clean_prefix = clean_prefix, clean_logfile = clean_logfile, wait_for_invalidation = wait_for_invalidation, each_strategy = each_strategy, all_strategies = all_strategies, validate_plugin_config_schema = validate_plugin_config_schema, clustering_client = clustering_client, get_clustering_protocols = get_clustering_protocols, https_server = https_server, stress_generator = stress_generator, -- miscellaneous intercept = intercept, openresty_ver_num = openresty_ver_num(), unindent = unindent, make_yaml_file = make_yaml_file, setenv = setenv, unsetenv = unsetenv, deep_sort = deep_sort, -- launching Kong subprocesses start_kong = start_kong, stop_kong = stop_kong, restart_kong = restart_kong, reload_kong = reload_kong, get_kong_workers = get_kong_workers, wait_until_no_common_workers = wait_until_no_common_workers, start_grpc_target = start_grpc_target, stop_grpc_target = stop_grpc_target, get_grpc_target_port = get_grpc_target_port, -- Only use in CLI tests from spec/02-integration/01-cmd kill_all = function(prefix, timeout) local kill = require "kong.cmd.utils.kill" local running_conf = get_running_conf(prefix) if not running_conf then return end -- kill kong_tests.conf service local pid_path = running_conf.nginx_pid if pl_path.exists(pid_path) then kill.kill(pid_path, "-TERM") wait_pid(pid_path, timeout) end end, with_current_ws = function(ws,fn, db) local old_ws = ngx.ctx.workspace ngx.ctx.workspace = nil ws = ws or {db.workspaces:select_by_name("default")} ngx.ctx.workspace = ws[1] and ws[1].id local res = fn() ngx.ctx.workspace = old_ws return res end, signal = function(prefix, signal, pid_path) local kill = require "kong.cmd.utils.kill" if not pid_path then local running_conf = get_running_conf(prefix) if not running_conf then error("no config file found at prefix: " .. prefix) end pid_path = running_conf.nginx_pid end return kill.kill(pid_path, signal) end, -- send signal to all Nginx workers, not including the master signal_workers = function(prefix, signal, pid_path) if not pid_path then local running_conf = get_running_conf(prefix) if not running_conf then error("no config file found at prefix: " .. prefix) end pid_path = running_conf.nginx_pid end local cmd = string.format("pkill %s -P `cat %s`", signal, pid_path) local _, code = pl_utils.execute(cmd) if not pid_dead(pid_path) then return false end return code end, -- returns the plugins and version list that is used by Hybrid mode tests get_plugins_list = function() assert(PLUGINS_LIST, "plugin list has not been initialized yet, " .. "you must call get_db_utils first") return table_clone(PLUGINS_LIST) end, get_available_port = function() local socket = require("socket") local server = assert(socket.bind("*", 0)) local _, port = server:getsockname() server:close() return tonumber(port) end, }