From 463f01070b0acc605dbb8c73950ea00058b10cde Mon Sep 17 00:00:00 2001 From: Xiaochen Wang Date: Wed, 29 Nov 2023 14:38:58 +0800 Subject: [PATCH] test(*): implement new HTTP mocking This commit backports HTTP mocking to release/3.2.x, including the following PRs from master branch: #10885, #11009, #11182 and #11331. --- .../01-helpers/03-http_mock_spec.lua | 306 ++++++++++++++++++ spec/helpers/http_mock.lua | 268 +++++++++++++++ spec/helpers/http_mock/asserts.lua | 165 ++++++++++ spec/helpers/http_mock/clients.lua | 31 ++ spec/helpers/http_mock/debug_port.lua | 118 +++++++ spec/helpers/http_mock/nginx_instance.lua | 86 +++++ spec/helpers/http_mock/tapping.lua | 47 +++ spec/helpers/http_mock/template.lua | 241 ++++++++++++++ 8 files changed, 1262 insertions(+) create mode 100644 spec/02-integration/01-helpers/03-http_mock_spec.lua create mode 100644 spec/helpers/http_mock.lua create mode 100644 spec/helpers/http_mock/asserts.lua create mode 100644 spec/helpers/http_mock/clients.lua create mode 100644 spec/helpers/http_mock/debug_port.lua create mode 100644 spec/helpers/http_mock/nginx_instance.lua create mode 100644 spec/helpers/http_mock/tapping.lua create mode 100644 spec/helpers/http_mock/template.lua diff --git a/spec/02-integration/01-helpers/03-http_mock_spec.lua b/spec/02-integration/01-helpers/03-http_mock_spec.lua new file mode 100644 index 00000000000..6ffb0770cde --- /dev/null +++ b/spec/02-integration/01-helpers/03-http_mock_spec.lua @@ -0,0 +1,306 @@ +local http_mock = require "spec.helpers.http_mock" +local tapping = require "spec.helpers.http_mock.tapping" +local pl_file = require "pl.file" + +for _, tls in ipairs {true, false} do + describe("http_mock with " .. (tls and "https" or "http") , function() + local mock, client + lazy_setup(function() + mock = assert(http_mock.new(nil, { + ["/"] = { + access = [[ + ngx.print("hello world") + ngx.exit(200) + ]] + }, + ["/404"] = { + access = [[ + ngx.exit(404) + ]] + } + }, { + eventually_timeout = 0.5, + tls = tls, + gen_client = true, + log_opts = { + resp = true, + resp_body = true + } + })) + + assert(mock:start()) + end) + + lazy_teardown(function() + assert(mock:stop()) + end) + + before_each(function() + client = mock:get_client() + end) + + after_each(function() + mock:clean() + -- it's an known issue of http_client that if we do not close the client, the next request will error out + client:close() + mock.client = nil + end) + + it("get #response", function() + local res = assert(client:send({})) + assert.response(res).has.status(200) + assert.same(res:read_body(), "hello world") + + mock.eventually:has_response_satisfy(function(resp) + assert.same(resp.body, "hello world") + end) + end) + + it("clean works", function() + client:send({}) + client:send({}) + mock:clean() + + assert.error(function() + mock.eventually:has_response_satisfy(function(resp) + assert.same(resp.body, "hello world") + end) + end) + end) + + it("clean works 2", function() + mock.eventually:has_no_response_satisfy(function(resp) + assert.same(resp.body, "hello world") + end) + end) + + it("mutiple request", function() + assert.response(assert(client:send({}))).has.status(200) + assert.response(assert(client:send({}))).has.status(200) + assert.response(assert(client:send({}))).has.status(200) + + local records = mock:retrieve_mocking_logs() + + assert.equal(3, #records) + end) + + it("request field", function() + assert.response(assert(client:send({}))).has.status(200) + + mock.eventually:has_request_satisfy(function(req) + assert.match("localhost:%d+", req.headers.Host) + assert(req.headers["User-Agent"]) + req.headers["Host"] = nil + req.headers["User-Agent"] = nil + assert.same(req, { + headers = {}, + method = "GET", + uri = "/" + }) + end) + end) + + it("http_mock assertion", function() + local function new_check(record, status) + assert.same(record.resp.status, status) + return "has a response with status " .. status + end + + http_mock.register_assert("status", new_check) + + assert.response(assert(client:send({}))).has.status(200) + assert.no_error(function() + mock.eventually:has_status(200) + end) + + assert.response(assert(client:send({}))).has.status(200) + assert.error(function() + mock.eventually:has_status(404) + end) + + assert.response(assert(client:send({}))).has.status(200) + assert.no_error(function() + mock.eventually:has_no_status(404) + end) + + assert.response(assert(client:send({}))).has.status(200) + assert.error(function() + mock.eventually:has_no_status(200) + end) + + assert.response(assert(client:send({}))).has.status(200) + assert.response(assert(client:send({}))).has.status(200) + assert.no_error(function() + mock.eventually:all_status(200) + end) + + assert.response(assert(client:send({}))).has.status(200) + assert.response(assert(client:send({ + path = "/404" + }))).has.status(404) + assert.error(function() + mock.eventually:all_status(200) + end) + + assert.response(assert(client:send({}))).has.status(200) + assert.response(assert(client:send({ + path = "/404" + }))).has.status(404) + assert.no_error(function() + mock.eventually:not_all_status(200) + end) + + assert.response(assert(client:send({}))).has.status(200) + assert.response(assert(client:send({}))).has.status(200) + assert.error(function() + mock.eventually:not_all_status(200) + end) + end) + end) +end + +describe("http_mock error catch", function() + it("error catch", function() + local mock = assert(http_mock.new(nil, [[ + error("hello world") + ngx.exit(200) + ]], { + eventually_timeout = 0.5, + tls = true, + gen_client = true, + log_opts = { + resp = true, + resp_body = true + } + })) + + finally(function() + assert(mock:stop()) + end) + + assert(mock:start()) + local client = mock:get_client() + local res = assert(client:send({})) + assert.response(res).has.status(500) + + mock.eventually:has_error_satisfy(function(err) + return assert.same("hello world", err[1][1]) + end) + + mock:clean() + -- then we have no Error + mock.eventually:has_no_error() + end) +end) + +describe("http_mock config", function() + it("default mocking", function() + local mock = assert(http_mock.new()) + assert(mock:start()) + finally(function() + assert(mock:stop()) + end) + local client = mock:get_client() + local res = assert(client:send({})) + assert.response(res).has.status(200) + assert.same(res:read_body(), "ok") + end) + + it("prefix", function() + local mock_prefix = "servroot_mock1" + local mock = assert(http_mock.new(nil, nil, { + prefix = mock_prefix + })) + mock:start() + finally(function() + assert(mock:stop()) + end) + + local pid_filename = mock_prefix .. "/logs/nginx.pid" + + assert(pl_file.access_time(pid_filename) ~= nil, "mocking not in the correct place") + end) + + it("init_by_lua_block inject", function () + local mock = assert(http_mock.new(nil, { + ["/test"] = { + access = [[ + ngx.print(test_value) + ]], + }, + }, { + init = [[ + -- Test that the mock is injected + test_value = "hello world" + ]] + })) + mock:start() + finally(function() + assert(mock:stop()) + end) + + local client = mock:get_client() + local res = assert(client:send({ + path = "/test" + })) + assert.response(res).has.status(200) + assert.same(res:read_body(), "hello world") + end) +end) + +local function remove_volatile_headers(req_t) + req_t.headers["Connection"] = nil + req_t.headers["Host"] = nil + req_t.headers["User-Agent"] = nil + req_t.headers["Content-Length"] = nil +end + +describe("http_mock.tapping", function() + local tapped, tapped_port + lazy_setup(function() + tapped, tapped_port = http_mock.new(nil, nil, { + log_opts = { + req = true, + req_body = true, + req_body_large = true, + } + }) + tapped:start() + end) + lazy_teardown(function() + tapped:stop(true) + end) + + it("works", function() + local tapping_mock = tapping.new(tapped_port) + tapping_mock:start() + finally(function() + tapping_mock:stop(true) + end) + local client = tapping_mock:get_client() + local request = { + headers = { + ["test"] = "mock_debug" + }, + method = "POST", + path = "/test!", + body = "hello world", + } + local res = assert(client:send(request)) + assert.response(res).has.status(200) + assert.same(res:read_body(), "ok") + + request.uri = request.path + request.path = nil + + local record = tapping_mock:retrieve_mocking_logs() + local req_t = assert(record[1].req) + remove_volatile_headers(req_t) + assert.same(request, req_t) + + local upstream_record = tapped:retrieve_mocking_logs() + local upstream_req_t = assert(upstream_record[1].req) + remove_volatile_headers(upstream_req_t) + assert.same(request, upstream_req_t) + end) +end) diff --git a/spec/helpers/http_mock.lua b/spec/helpers/http_mock.lua new file mode 100644 index 00000000000..c1c998a864a --- /dev/null +++ b/spec/helpers/http_mock.lua @@ -0,0 +1,268 @@ +--- Module implementing http_mock, a HTTP mocking server for testing. +-- @module spec.helpers.http_mock + +local helpers = require "spec.helpers" + +local pairs = pairs +local ipairs = ipairs +local type = type +local setmetatable = setmetatable + +local modules = { + require "spec.helpers.http_mock.nginx_instance", + require "spec.helpers.http_mock.asserts", + require "spec.helpers.http_mock.debug_port", + require "spec.helpers.http_mock.clients", +} + +local http_mock = {} + +-- since http_mock contains a lot of functionality, it is implemented in separate submodules +-- and combined into one large http_mock module here. +for _, module in ipairs(modules) do + for k, v in pairs(module) do + http_mock[k] = v + end +end + +-- get a session from the logs with a timeout +-- throws error if no request is recieved within the timeout +-- @treturn table the session +function http_mock:get_session() + local ret + self.eventually:has_session_satisfy(function(s) + ret = s + return true + end) + return ret +end + +-- get a request from the logs with a timeout +-- throws error if no request is recieved within the timeout +-- @treturn table the request +function http_mock:get_request() + return self:get_session().req +end + +-- get a response from the logs with a timeout +-- throws error if no request is recieved within the timeout +-- @treturn table the response +function http_mock:get_response() + return self:get_session().resp +end + +local http_mock_MT = { __index = http_mock, __gc = http_mock.stop } + + +-- TODO: make default_mocking the same to the `mock_upstream` +local default_mocking = { + ["/"] = { + access = [[ + ngx.req.set_header("X-Test", "test") + ngx.print("ok") + ngx.exit(200) + ]], + }, +} + +local function default_field(tbl, key, default) + if tbl[key] == nil then + tbl[key] = default + end +end + +--- create a mock instance which represents a HTTP mocking server +-- @tparam[opt] table|string|number listens the listen directive of the mock server. This can be +-- a single directive (string), or a list of directives (table), or a number which will be used as the port. +-- Defaults to a random available port +-- @tparam[opt] table|string routes the code of the mock server, defaults to a simple response. See Examples. +-- @tparam[opt={}] table opts options for the mock server, supporting fields: +-- @tparam[opt="servroot_tapping"] string opts.prefix the prefix of the mock server +-- @tparam[opt="_"] string opts.hostname the hostname of the mock server +-- @tparam[opt=false] bool opts.tls whether to use tls +-- @tparam[opt={}] table opts.directives the extra directives of the mock server +-- @tparam[opt={}] table opts.log_opts the options for logging with fields listed below: +-- @tparam[opt=true] bool opts.log_opts.collect_req whether to log requests() +-- @tparam[opt=true] bool opts.log_opts.collect_req_body_large whether to log large request bodies +-- @tparam[opt=false] bool opts.log_opts.collect_resp whether to log responses +-- @tparam[opt=false] bool opts.log_opts.collect_resp_body whether to log response bodies +-- @tparam[opt=true] bool opts.log_opts.collect_err: whether to log errors +-- @tparam[opt] string opts.init: the lua code injected into the init_by_lua_block +-- @treturn http_mock a mock instance +-- @treturn string the port the mock server listens to +-- @usage +-- local mock = http_mock.new(8000, [[ +-- ngx.req.set_header("X-Test", "test") +-- ngx.print("hello world") +-- ]], { +-- prefix = "mockserver", +-- log_opts = { +-- resp = true, +-- resp_body = true, +-- }, +-- tls = true, +-- }) +-- +-- mock:start() +-- local client = mock:get_client() -- get a client to access the mocking port +-- local res = assert(client:send({})) +-- assert.response(res).has.status(200) +-- assert.response(res).has.header("X-Test", "test") +-- assert.response(res).has.body("hello world") +-- mock.eventually:has_response(function(resp) +-- assert.same(resp.body, "hello world") +-- end) +-- mock:wait_until_no_request() -- wait until all the requests are finished +-- mock:clean() -- clean the logs +-- client:send({}) +-- client:send({}) +-- local logs = mock:retrieve_mocking_logs() -- get all the logs of HTTP sessions +-- mock:stop() +-- @usage +-- -- routes can be a table like this: +-- routes = { +-- ["/"] = { +-- access = [[ +-- ngx.req.set_header("X-Test", "test") +-- ngx.print("hello world") +-- ]], +-- log = [[ +-- ngx.log(ngx.ERR, "log test!") +-- ]], +-- directives = { +-- "rewrite ^/foo /bar break;", +-- }, +-- }, +-- } +-- +-- -- or single a string, which will be used as the access phase handler. +-- routes = [[ ngx.print("hello world") ]] +-- -- which is equivalent to: +-- routes = { +-- ["/"] = { +-- access = [[ ngx.print("hello world") ]], +-- }, +-- } +function http_mock.new(listens, routes, opts) + opts = opts or {} + + if listens == nil then + listens = helpers.get_available_port() + end + + if type(listens) == "number" then + listens = "0.0.0.0:" .. listens .. (opts.tls and " ssl" or "") + end + + if type(listens) == "string" then + listens = { listens, } + end + + if routes == nil then + routes = default_mocking + elseif type(routes) == "string" then + routes = { + ["/"] = { + access = routes, + } + } + end + + opts.log_opts = opts.log_opts or {} + local log_opts = opts.log_opts + default_field(log_opts, "req", true) + default_field(log_opts, "req_body_large", true) + -- usually we can check response from client side + default_field(log_opts, "resp", false) + default_field(log_opts, "resp_body", false) + default_field(log_opts, "err", true) + + local prefix = opts.prefix or "servroot_mock" + local hostname = opts.hostname or "_" + local directives = opts.directives or {} + + local _self = setmetatable({ + prefix = prefix, + hostname = hostname, + listens = listens, + routes = routes, + directives = directives, + init = opts.init, + log_opts = log_opts, + logs = {}, + tls = opts.tls, + eventually_timeout = opts.eventually_timeout or 5, + }, http_mock_MT) + + local port = _self:get_default_port() + + if port then + _self.client_opts = { + port = port, + tls = opts.tls, + } + end + + _self:_set_eventually_table() + _self:_setup_debug() + return _self, port +end + +--- @type http_mock + +--- returns the default port of the mock server. +-- @function http_mock:get_default_port +-- @treturn string the port of the mock server (from the first listen directive) +function http_mock:get_default_port() + return self.listens[1]:match(":(%d+)") +end + +--- retrieve the logs of HTTP sessions +-- @function http_mock:retrieve_mocking_logs +-- @treturn table the logs of HTTP sessions + +--- purge the logs of HTTP sessions +-- @function http_mock:purge_mocking_logs + +--- clean the logs of HTTP sessions +-- @function http_mock:clean + +--- wait until all the requests are finished +-- @function http_mock:wait_until_no_request +-- @tparam[opt=true,default=5] number timeout the timeout to wait for the nginx process to exit + +--- make assertions on HTTP requests. +-- with a timeout to wait for the requests to arrive +-- @class http_mock.eventually + +--- assert if the condition is true for one of the logs. +-- Replace "session" in the name of the function to assert on fields of the log. +-- The field can be one of "session", "request", "response", "error". +-- @function http_mock.eventually:has_session_satisfy +-- @tparam function check the check function, accept a log and throw error if the condition is not satisfied + +--- assert if the condition is true for all the logs. +-- Replace "session" in the name of the function to assert on fields of the log. +-- The field can be one of "session", "request", "response", "error". +-- @function http_mock.eventually:all_session_satisfy +-- @tparam function check the check function, accept a log and throw error if the condition is not satisfied + +--- assert if none of the logs satisfy the condition. +-- Replace "session" in the name of the function to assert on fields of the log. +-- The field can be one of "session", "request", "response", "error". +-- @function http_mock.eventually:has_no_session_satisfy +-- @tparam function check the check function, accept a log and throw error if the condition is not satisfied + +--- assert if not all the logs satisfy the condition. +-- Replace "session" in the name of the function to assert on fields of the log. +-- The field can be one of "session", "request", "response", "error". +-- @function http_mock.eventually:not_all_session_satisfy +-- @tparam function check the check function, accept a log and throw error if the condition is not satisfied + +--- alias for eventually:not_all_{session,request,response,error}_satisfy. +-- Replace "session" in the name of the function to assert on fields of the log. +-- The field can be one of "session", "request", "response", "error". +-- @function http_mock.eventually:has_one_without_session_satisfy +-- @tparam function check the check function, accept a log and throw error if the condition is not satisfied + +return http_mock diff --git a/spec/helpers/http_mock/asserts.lua b/spec/helpers/http_mock/asserts.lua new file mode 100644 index 00000000000..8d3705c90b5 --- /dev/null +++ b/spec/helpers/http_mock/asserts.lua @@ -0,0 +1,165 @@ +local setmetatable = setmetatable +local ipairs = ipairs +local pairs = pairs +local pcall = pcall +local error = error + +---@class http_mock +local http_mock = {} + +local build_in_checks = {} + +---@class http_mock_asserts +local eventually_MT = {} +eventually_MT.__index = eventually_MT + +local step_time = 0.01 + +-- example for a check function +-- local function(session, status) +-- -- must throw error if the assertion is not true +-- -- instead of return false +-- assert.same(session.resp.status, status) +-- -- return a string to tell what condition is satisfied +-- -- so we can construct an error message for reverse assertion +-- -- in this case it would be "we don't expect that: has a response with status 200" +-- return "has a response with status " .. status +-- end + +local function eventually_has(check, mock, ...) + local time = 0 + local ok, err + while time < mock.eventually_timeout do + local logs = mock:retrieve_mocking_logs() + for _, log in ipairs(logs) do + -- use pcall so the user may use lua assert like assert.same + ok, err = pcall(check, log, ...) + if ok then + return true + end + end + + ngx.sleep(step_time) + time = time + step_time + end + + error(err or "assertion fail. No request is sent and recorded.", 2) +end + +-- wait until timeout to check if the assertion is true for all logs +local function eventually_all(check, mock, ...) + local time = 0 + local ok, err + while time < mock.eventually_timeout do + local logs = mock:retrieve_mocking_logs() + for _, log in ipairs(logs) do + ok, err = pcall(check, log, ...) + if not ok then + error(err or "assertion fail", 2) + end + end + + ngx.sleep(step_time) + time = time + step_time + end + + return true +end + +-- a session is a request/response pair +function build_in_checks.session_satisfy(session, f) + return f(session) or "session satisfy" +end + +function build_in_checks.request_satisfy(session, f) + return f(session.req) or "request satisfy" +end + +function build_in_checks.request() + return "request exist" +end + +function build_in_checks.response_satisfy(session, f) + return f(session.resp) or "response satisfy" +end + +function build_in_checks.error_satisfy(session, f) + return f(session.err) or "error satisfy" +end + +function build_in_checks.error(session) + assert(session.err, "has no error") + return "has error" +end + +local function register_assert(name, impl) + eventually_MT["has_" .. name] = function(self, ...) + return eventually_has(impl, self.__mock, ...) + end + + eventually_MT["all_" .. name] = function(self, ...) + return eventually_all(impl, self.__mock, ...) + end + + local function reverse_impl(session, ...) + local ok, err = pcall(impl, session, ...) + if ok then + error("we don't expect that: " .. (name or err), 2) + end + return true + end + + eventually_MT["has_no_" .. name] = function(self, ...) + return eventually_all(reverse_impl, self.__mock, ...) + end + + eventually_MT["not_all_" .. name] = function(self, ...) + return eventually_has(reverse_impl, self.__mock, ...) + end + + eventually_MT["has_one_without_" .. name] = eventually_MT["not_all_" .. name] +end + +for name, impl in pairs(build_in_checks) do + register_assert(name, impl) +end + + +function http_mock:_set_eventually_table() + local eventually = setmetatable({}, eventually_MT) + eventually.__mock = self + self.eventually = eventually + return eventually +end + +-- usually this function is not called by a user. I will add more assertions in the future with it. @StarlightIbuki + +-- @function http_mock.register_assert() +-- @param name: the name of the assertion +-- @param impl: the implementation of the assertion +-- implement a new eventually assertion +-- @usage: +-- impl is a function +-- -- @param session: the session object, with req, resp, err, start_time, end_time as fields +-- -- @param ...: the arguments passed to the assertion +-- -- @return: human readable message if the assertion is true, or throw error if not +-- +-- a session means a request/response pair. +-- The impl callback throws error if the assertion is not true +-- and returns a string to tell what condition is satisfied +-- This design is to allow the user to use lua asserts in the callback +-- (or even callback the registered assertion accept as argument), like the example; +-- and for has_no/not_all assertions, we can construct an error message for it like: +-- "we don't expect that: has header foo" +-- @example: +-- http_mock.register_assert("req_has_header", function(mock, name) +-- assert.same(name, session.req.headers[name]) +-- return "has header " .. name +-- end) +-- mock.eventually:has_req_has_header("foo") +-- mock.eventually:has_no_req_has_header("bar") +-- mock.eventually:all_req_has_header("baz") +-- mock.eventually:not_all_req_has_header("bar") +http_mock.register_assert = register_assert + +return http_mock diff --git a/spec/helpers/http_mock/clients.lua b/spec/helpers/http_mock/clients.lua new file mode 100644 index 00000000000..98f560e93d1 --- /dev/null +++ b/spec/helpers/http_mock/clients.lua @@ -0,0 +1,31 @@ +--- part of http_mock +-- @submodule spec.helpers.http_mock + +local helpers = require "spec.helpers" +local http_client = helpers.http_client + +local http_mock = {} + +--- get a `helpers.http_client` to access the mock server +-- @function http_mock:get_client +-- @treturn http_client a `helpers.http_client` instance +-- @within http_mock +-- @usage +-- httpc = http_mock:get_client() +-- result = httpc:get("/services/foo", opts) +function http_mock:get_client() + local client = self.client + if not client then + client = http_client({ + scheme = self.client_opts.tls and "https" or "http", + host = "localhost", + port = self.client_opts.port, + }) + + self.client = client + end + + return client +end + +return http_mock diff --git a/spec/helpers/http_mock/debug_port.lua b/spec/helpers/http_mock/debug_port.lua new file mode 100644 index 00000000000..e5db9e5327f --- /dev/null +++ b/spec/helpers/http_mock/debug_port.lua @@ -0,0 +1,118 @@ +local helpers = require "spec.helpers" +local http = require "resty.http" +local cjson = require "cjson" +local match = string.match +local ipairs = ipairs +local insert = table.insert +local assert = assert + +---@class http_mock +local http_mock = {} + +-- POST as it's not idempotent +local retrieve_mocking_logs_param = { + method = "POST", + path = "/logs", + headers = { + ["Host"] = "mock_debug" + } +} + +local purge_mocking_logs_param = { + method = "DELETE", + path = "/logs", + headers = { + ["Host"] = "mock_debug" + } +} + +local get_status_param = { + method = "GET", + path = "/status", + headers = { + ["Host"] = "mock_debug" + } +} + +-- internal API +function http_mock:_setup_debug(debug_param) + local debug_port = helpers.get_available_port() + local debug_client = assert(http.new()) + local debug_connect = { + scheme = "http", + host = "localhost", + port = debug_port, + } + + self.debug = { + port = debug_port, + client = debug_client, + connect = debug_connect, + param = debug_param, + } +end + +function http_mock:debug_connect() + local debug = self.debug + local client = debug.client + assert(client:connect(debug.connect)) + return client +end + +function http_mock:retrieve_mocking_logs_json() + local debug = self:debug_connect() + local res = assert(debug:request(retrieve_mocking_logs_param)) + assert(res.status == 200) + local body = assert(res:read_body()) + debug:close() + return body +end + +function http_mock:purge_mocking_logs() + local debug = self:debug_connect() + local res = assert(debug:request(purge_mocking_logs_param)) + assert(res.status == 204) + debug:close() + return true +end + +function http_mock:retrieve_mocking_logs() + local new_logs = cjson.decode(self:retrieve_mocking_logs_json()) + for _, log in ipairs(new_logs) do + insert(self.logs, log) + end + + return new_logs +end + +function http_mock:wait_until_no_request(timeout) + local debug = self:debug_connect() + + -- wait until we have no requests on going + helpers.wait_until(function() + local res = assert(debug:request(get_status_param)) + assert(res.status == 200) + local body = assert(res:read_body()) + local reading, writing, _ = match(body, "Reading: (%d+) Writing: (%d+) Waiting: (%d+)") + -- the status is the only request + return assert(reading) + assert(writing) <= 1 + end, timeout) +end + +function http_mock:get_all_logs(timeout) + self:wait_until_no_request(timeout) + self:retrieve_mocking_logs() + return self.logs +end + +function http_mock:clean(timeout) + -- if we wait, the http_client may timeout and cause error + -- self:wait_until_no_request(timeout) + + -- clean unwanted logs + self.logs = {} + self:purge_mocking_logs() + return true +end + +return http_mock diff --git a/spec/helpers/http_mock/nginx_instance.lua b/spec/helpers/http_mock/nginx_instance.lua new file mode 100644 index 00000000000..860a12439f6 --- /dev/null +++ b/spec/helpers/http_mock/nginx_instance.lua @@ -0,0 +1,86 @@ +--- part of http_mock +-- @submodule spec.helpers.http_mock + +local template_str = require "spec.helpers.http_mock.template" +local pl_template = require "pl.template" +local pl_path = require "pl.path" +local pl_dir = require "pl.dir" +local pl_file = require "pl.file" +local pl_utils = require "pl.utils" +local os = require "os" + +local print = print +local error = error +local assert = assert +local ngx = ngx +local io = io +local shallow_copy = require "kong.tools.utils".shallow_copy + +local template = assert(pl_template.compile(template_str)) +local render_env = {ipairs = ipairs, pairs = pairs, error = error, } +local http_mock = {} + +--- start a dedicate nginx instance for this mock +-- @tparam[opt=false] bool error_on_exist whether to throw error if the directory already exists +-- @within http_mock +-- @usage http_mock:start(true) +function http_mock:start(error_on_exist) + local ok = (pl_path.mkdir(self.prefix)) + and (pl_path.mkdir(self.prefix .. "/logs")) + and (pl_path.mkdir(self.prefix .. "/conf")) + if error_on_exist then assert(ok, "failed to create directory " .. self.prefix) end + + local render = assert(template:render(shallow_copy(self), render_env)) + local conf_path = self.prefix .. "/conf/nginx.conf" + local conf_file = assert(io.open(conf_path, "w")) + assert(conf_file:write(render)) + assert(conf_file:close()) + + local cmd = "nginx -p " .. self.prefix + local ok, code, _, stderr = pl_utils.executeex(cmd) + assert(ok and code == 0, "failed to start nginx: " .. stderr) + return true +end + +local sleep_step = 0.01 + +--- stop a dedicate nginx instance for this mock +-- @function http_mock:stop +-- @tparam[opt=false] bool no_clean whether to preserve the logs +-- @tparam[opt="TERM"] string signal the signal name to send to the nginx process +-- @tparam[opt=10] number timeout the timeout to wait for the nginx process to exit +-- @within http_mock +-- @usage http_mock:stop(false, "TERM", 10) +function http_mock:stop(no_clean, signal, timeout) + signal = signal or "TERM" + timeout = timeout or 10 + local pid_filename = self.prefix .. "/logs/nginx.pid" + local pid_file = assert(io.open(pid_filename, "r")) + local pid = assert(pid_file:read("*a")) + pid_file:close() + + local kill_nginx_cmd = "kill -s " .. signal .. " " .. pid + if not os.execute(kill_nginx_cmd) then + error("failed to kill nginx at " .. self.prefix, 2) + end + + local time = 0 + while pl_file.access_time(pid_filename) ~= nil do + ngx.sleep(sleep_step) + time = time + sleep_step + if(time > timeout) then + error("nginx does not exit at " .. self.prefix, 2) + end + end + + if no_clean then return true end + + local _, err = pl_dir.rmtree(self.prefix) + if err then + print("could not remove ", self.prefix, ": ", tostring(err)) + end + + return true +end + +return http_mock diff --git a/spec/helpers/http_mock/tapping.lua b/spec/helpers/http_mock/tapping.lua new file mode 100644 index 00000000000..65e84435d20 --- /dev/null +++ b/spec/helpers/http_mock/tapping.lua @@ -0,0 +1,47 @@ +--- A http_mock subclass for tapping. +-- @module spec.helpers.http_mock.tapping + +local http_mock = require "spec.helpers.http_mock" + +local tapping = {} + +-- create a new tapping route +-- @tparam string|number target the target host/port of the tapping route +-- @return the tapping route instance +function tapping.new_tapping_route(target) + if tonumber(target) then + -- TODO: handle the resovler! + target = "http://127.0.0.1:" .. target + end + + if not target:find("://") then + target = "http://" .. target + end + + return { + ["/"] = { + directives = [[proxy_pass ]] .. target .. [[;]], + } + } +end + +--- create a new `http_mock.tapping` instance with a tapping route +-- @tparam string|number target the target host/port of the tapping route +-- @tparam[opt] table|string|number listens see `http_mock.new` +-- @tparam[opt="servroot_tapping"] string prefix the prefix of the mock server +-- @tparam[opt={}] table log_opts see `http_mock.new`, uses the defaults, with `req_large_body` enabled +-- @treturn http_mock.tapping a tapping instance +-- @treturn string the port the mock server listens to +function tapping.new(target, listens, prefix, log_opts) + ---@diagnostic disable-next-line: return-type-mismatch + return http_mock.new(listens, tapping.new_tapping_route(target), { + prefix = prefix or "servroot_tapping", + log_opts = log_opts or { + req = true, + req_body = true, + req_large_body = true, + }, + }) +end + +return tapping diff --git a/spec/helpers/http_mock/template.lua b/spec/helpers/http_mock/template.lua new file mode 100644 index 00000000000..510cfad8c8c --- /dev/null +++ b/spec/helpers/http_mock/template.lua @@ -0,0 +1,241 @@ +return [[ +# if not hostname then +# hostname = "_" +# end +# if not debug.port then +# error("debug.port is required") +# end +# if not shm_size then +# shm_size = "20m" +# end +daemon on; +# if not worker_num then +# worker_num = 1 +# end +worker_processes $(worker_num); +error_log logs/error.log info; +pid logs/nginx.pid; +worker_rlimit_nofile 8192; + +events { + worker_connections 1024; +} + +http { + lua_shared_dict mock_logs $(shm_size); + + init_by_lua_block { +# if log_opts.err then + -- disable warning of global variable + local g_meta = getmetatable(_G) + setmetatable(_G, nil) + + original_assert = assert -- luacheck: ignore + + local function insert_err(err) + local err_t = ngx.ctx.err + if not err_t then + err_t = {} + ngx.ctx.err = err_t + end + table.insert(err_t, {err, debug.traceback("", 3)}) + end + + function assert(truthy, err, ...) -- luacheck: ignore + if not truthy and ngx.ctx then + insert_err(err) + end + + return original_assert(truthy, err, ...) + end + + original_error = error -- luacheck: ignore + + function error(msg, ...) -- luacheck: ignore + if ngx.ctx then + insert_err(msg) + end + + return original_error(msg, ...) + end + + err_patched = true -- luacheck: ignore + + setmetatable(_G, g_meta) +# end +# if init then +$(init) +# end + } + + server { + listen 0.0.0.0:$(debug.port); + server_name mock_debug; + + location = /status { + stub_status; + } + + location /logs { + default_type application/json; + + access_by_lua_block { + local mock_logs = ngx.shared.mock_logs + + if ngx.req.get_method() == "DELETE" then + mock_logs:flush_all() + return ngx.exit(204) + end + + if ngx.req.get_method() ~= "POST" then + return ngx.exit(405) + end + + ngx.print("[") + local ele, err + repeat + local old_ele = ele + ele, err = mock_logs:lpop("mock_logs") + if old_ele and ele then + ngx.print(",", ele) + elseif ele then + ngx.print(ele) + end + if err then + return ngx.exit(500) + end + until not ele + ngx.print("]") + ngx.exit(200) + } + } + } + + server { +# for _, listen in ipairs(listens or {}) do + listen $(listen); +# end + server_name $(hostname); + +# for _, directive in ipairs(directives or {}) do + $(directive) + +# end +# if tls then + ssl_certificate ../../spec/fixtures/kong_spec.crt; + ssl_certificate_key ../../spec/fixtures/kong_spec.key; + ssl_protocols TLSv1 TLSv1.1 TLSv1.2; + ssl_ciphers HIGH:!aNULL:!MD5; + +# end +# for location, route in pairs(routes or {}) do + location $(location) { +# if route.directives then + $(route.directives) + +# end +# if route.access or log_opts.req then + access_by_lua_block { +# if log_opts.req then + -- collect request + local method = ngx.req.get_method() + local uri = ngx.var.request_uri + local headers = ngx.req.get_headers(nil, true) + + + ngx.req.read_body() + local body +# if log_opts.req_body then + -- collect body + body = ngx.req.get_body_data() +# if log_opts.req_large_body then + if not body then + local file = ngx.req.get_body_file() + if file then + local f = io.open(file, "r") + if f then + body = f:read("*a") + f:close() + end + end + end +# end -- if log_opts.req_large_body +# end -- if log_opts.req_body + ngx.ctx.req = { + method = method, + uri = uri, + headers = headers, + body = body, + } + +# end -- if log_opts.req +# if route.access then + $(route.access) +# end + } +# end + +# if route.header_filter then + header_filter_by_lua_block { + $(route.header) + } + +# end +# if route.content then + content_by_lua_block { + $(route.content) + } + +# end +# if route.body_filter or log_opts.resp_body then + body_filter_by_lua_block { +# if route.body_filter then + $(route.body) + +# end +# if log_opts.resp_body then + -- collect body + ngx.ctx.resp_body = ngx.ctx.resp_body or {} + if not ngx.arg[2] then + table.insert(ngx.ctx.resp_body, ngx.arg[1]) + end +# end -- if log_opts.resp_body + } + +# end + log_by_lua_block { +# if route.log then + $(route.log) + +# end + -- collect session data + local cjson = require "cjson" + local start_time = ngx.req.start_time() + local end_time = ngx.now() + + local req = ngx.ctx.req or {} + local resp +# if log_opts.resp then + resp = { + status = ngx.status, + headers = ngx.resp.get_headers(nil, true), + body = ngx.ctx.resp_body and table.concat(ngx.ctx.resp_body), + } +# else -- if log_opts.resp + resp = {} +# end -- if log_opts.resp + local err = ngx.ctx.err + + ngx.shared.mock_logs:rpush("mock_logs", cjson.encode({ + start_time = start_time, + end_time = end_time, + req = req, + resp = resp, + err = err, + })) + } + } +# end -- for location, route in pairs(routes) + } +} +]]